1use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20 Contested, ContestedItem, Followup, Issue, IssueRef, ItemKind, PersistedState, PrRef, PrRow,
21 PrView,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, logdim, logwarn, spar_err};
27
28pub const FETCH_CEILING: usize = 500;
32
33pub const STATE_MARKER: &str = "<!-- spar:state";
36
37pub const COMMENT_MARKER: &str = "<!-- spar:comment -->";
45
46pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
54
55const WORKTREE_DIR: &str = ".spar-worktrees";
56const STATE_DIR: &str = ".spar";
57
58pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
60pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub(crate) enum Disposable {
65 BuildOutput,
68 ReviewOutput,
76}
77
78pub(crate) fn is_spar_marker(path: &Path) -> bool {
86 let mut components = path.components();
87 let Some(std::path::Component::Normal(name)) = components.next() else {
88 return false;
89 };
90 if components.next().is_some() {
91 return false;
92 }
93 let Some(name) = name.to_str() else {
94 return false;
95 };
96 name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
97}
98
99const SPLIT_SLOTS: u32 = 20;
104
105#[derive(Debug, Clone)]
106pub struct SplitPushError {
107 message: String,
108 retain_worktree: bool,
109}
110
111impl SplitPushError {
112 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
113 Self {
114 message: message.into(),
115 retain_worktree,
116 }
117 }
118
119 pub fn retain_worktree(&self) -> bool {
120 self.retain_worktree
121 }
122}
123
124impl std::fmt::Display for SplitPushError {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.write_str(&self.message)
127 }
128}
129
130impl std::error::Error for SplitPushError {}
131
132fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
137 match attempt {
138 1 => format!("split-{parent}-{index}"),
139 n => format!("split-{parent}-{index}-{n}"),
140 }
141}
142
143#[derive(Debug)]
144pub struct Repo {
145 root: PathBuf,
146 pub style: Style,
147 pub branch_prefix: String,
148 pub state_store: StateStore,
149 pub followups: Followups,
150 pub drafts: Drafts,
151 viewer: OnceLock<String>,
157 checkpoints: Mutex<BTreeMap<i64, u64>>,
162 writes: WriteStats,
163}
164
165#[derive(Debug, Default)]
166struct WriteStats {
167 attempted: AtomicUsize,
168 failed: AtomicUsize,
169}
170
171#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
172pub(crate) struct WriteSummary {
173 pub(crate) attempted: usize,
174 pub(crate) failed: usize,
175}
176
177impl WriteSummary {
178 pub(crate) fn succeeded(self) -> usize {
179 self.attempted.saturating_sub(self.failed)
180 }
181}
182
183#[derive(Debug, Clone)]
189pub(crate) struct WorktreeBaseline {
190 attributes: AttributeState,
191 ignored_untracked: IgnoredState,
192 git_state: GitState,
193}
194
195#[derive(Debug, Clone)]
201pub(crate) struct WorktreeCheckpoint {
202 path: PathBuf,
203 attributes: AttributeState,
204 git_state: GitState,
205 ignored_untracked: IgnoredState,
206}
207
208#[derive(Debug, Clone, Default, PartialEq, Eq)]
209pub(crate) struct AttributeState {
210 files: BTreeMap<PathBuf, [u8; 32]>,
211}
212
213#[derive(Debug, Clone, Default, PartialEq, Eq)]
218pub(crate) struct IgnoredState {
219 files: BTreeMap<PathBuf, UntrackedFile>,
220 ignored: BTreeSet<PathBuf>,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
230struct UntrackedFile {
231 kind: u8,
232 len: u64,
233 modified: Option<std::time::SystemTime>,
234 created: Option<std::time::SystemTime>,
235 readonly: bool,
236 symlink_target: Option<Vec<u8>>,
237 #[cfg(unix)]
238 device: u64,
239 #[cfg(unix)]
240 inode: u64,
241 #[cfg(unix)]
242 mode: u32,
243 #[cfg(unix)]
244 change_seconds: i64,
245 #[cfg(unix)]
246 change_nanoseconds: i64,
247}
248
249#[derive(Debug, Clone, Default, PartialEq, Eq)]
250pub(crate) struct GitState {
251 repositories: BTreeMap<PathBuf, RepositoryState>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
255struct RepositoryState {
256 head: String,
257 unsafe_index_flags: Vec<u8>,
258 tracked: BTreeMap<PathBuf, TrackedEntry>,
259 gitlinks: BTreeMap<PathBuf, String>,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
263struct TrackedEntry {
264 index_mode: String,
265 index_oid: String,
266 worktree: Option<WorktreeFile>,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
270struct WorktreeFile {
271 mode: String,
272 #[cfg(unix)]
273 permissions: u32,
274 raw_oid: String,
275 fingerprint: [u8; 32],
276 content: [u8; 32],
277}
278
279struct Gitlink {
280 path: PathBuf,
281 oid: String,
282}
283
284struct IndexEntry {
285 path: PathBuf,
286 mode: String,
287 oid: String,
288}
289
290impl IgnoredState {
291 fn is_ignored(&self, path: &Path) -> bool {
292 self.ignored.contains(path)
293 }
294
295 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
296 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
297 paths.extend(after.files.keys().cloned());
298 paths
299 .into_iter()
300 .filter(|path| self.files.get(path) != after.files.get(path))
301 .collect()
302 }
303
304 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
305 self.files
306 .iter()
307 .filter(|(path, state)| after.files.get(*path) != Some(*state))
308 .map(|(path, _)| path.clone())
309 .collect()
310 }
311
312 fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
313 after
314 .files
315 .keys()
316 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
317 .cloned()
318 .collect()
319 }
320
321 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
331 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
332 paths.extend(after.files.keys());
333 paths.into_iter().any(|path| {
334 if self.files.get(path) == after.files.get(path)
335 && self.is_ignored(path) == after.is_ignored(path)
336 {
337 return false;
338 }
339 !(is_generated_artifact(path) && self.disposable(path) && after.disposable(path))
340 })
341 }
342
343 pub(crate) fn changed_beyond_ignored(&self, after: &Self) -> bool {
360 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
361 paths.extend(after.files.keys());
362 paths.into_iter().any(|path| {
363 if self.files.get(path) == after.files.get(path)
364 && self.is_ignored(path) == after.is_ignored(path)
365 {
366 return false;
367 }
368 if is_generated_artifact(path) && self.disposable(path) && after.disposable(path) {
369 return false;
370 }
371 self.files.contains_key(path) || !after.is_ignored(path)
372 })
373 }
374
375 fn disposable(&self, path: &Path) -> bool {
378 !self.files.contains_key(path) || self.is_ignored(path)
379 }
380}
381
382#[derive(Default)]
389struct GeneratedArtifacts {
390 new_paths: BTreeSet<PathBuf>,
391 changed_paths: BTreeSet<PathBuf>,
392}
393
394impl GeneratedArtifacts {
395 fn left(&mut self, paths: Vec<PathBuf>) {
396 self.new_paths.extend(paths);
397 }
398
399 fn changed(&mut self, paths: Vec<PathBuf>) {
400 self.changed_paths.extend(paths);
401 }
402
403 fn report(&self, cwd: &Path) {
407 if !self.new_paths.is_empty() {
408 logdim!(
409 "the editing call left {} generated artifact(s) under a known build or cache \
410 directory in {}. They are not part of the commit.",
411 self.new_paths.len(),
412 cwd.display()
413 );
414 }
415 if !self.changed_paths.is_empty() {
416 logdim!(
417 "the editing call changed {} existing generated artifact(s) under a known build \
418 or cache directory in {}. They are not part of the commit.",
419 self.changed_paths.len(),
420 cwd.display()
421 );
422 }
423 }
424}
425
426fn is_generated_artifact(path: &Path) -> bool {
432 const DIRECTORIES: &[&str] = &[
433 "target",
434 "dist",
435 "node_modules",
436 "__pycache__",
437 ".pytest_cache",
438 ".mypy_cache",
439 ".ruff_cache",
440 ".tox",
441 ".nox",
442 ".venv",
443 "venv",
444 ".gradle",
445 ".build",
446 "DerivedData",
447 ".next",
448 ".nuxt",
449 ".svelte-kit",
450 ".turbo",
451 "coverage",
452 ];
453 path.components().any(|component| {
454 let std::path::Component::Normal(name) = component else {
455 return false;
456 };
457 DIRECTORIES
458 .iter()
459 .any(|directory| name == OsStr::new(directory))
460 })
461}
462
463fn merge_pr_args<'a>(
464 number: &'a str,
465 expected_head: Option<&'a str>,
466 delete_branch: bool,
467) -> Vec<&'a str> {
468 let mut args = vec!["pr", "merge", number, "--squash"];
469 if delete_branch {
470 args.push("--delete-branch");
471 }
472 if let Some(expected_head) = expected_head {
473 args.extend(["--match-head-commit", expected_head]);
474 }
475 args
476}
477
478fn reconcile_pr_creation(
479 branch: &str,
480 created: Result<String>,
481 found: Result<Option<PrRef>>,
482) -> Result<PrRef> {
483 match (created, found) {
484 (_, Ok(Some(pr))) => Ok(pr),
485 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
486 "PR creation reported success but none was found for {branch}"
487 ))),
488 (Err(create), Ok(None)) => Err(spar_err!(
489 "could not open a PR for {branch}. {}",
490 create.last_line()
491 )),
492 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
493 "PR creation reported success for {branch}, but it could not be verified. {}",
494 check.last_line()
495 ))),
496 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
497 "could not open a PR for {branch}. {} The result could not be verified: {}",
498 create.last_line(),
499 check.last_line()
500 ))),
501 }
502}
503
504fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
505 #[derive(Deserialize)]
506 #[serde(rename_all = "camelCase")]
507 struct Row {
508 number: i64,
509 #[serde(default)]
510 url: String,
511 #[serde(default)]
512 title: String,
513 base_ref_name: String,
514 }
515
516 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
517 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
518 })?;
519 Ok(rows
520 .into_iter()
521 .find(|row| row.base_ref_name == base)
522 .map(|row| PrRef {
523 number: row.number,
524 url: row.url,
525 title: row.title,
526 }))
527}
528
529fn signed(body: &str) -> String {
532 if body.contains(COMMENT_MARKER) {
533 return body.to_string();
534 }
535 format!("{body}\n\n{COMMENT_MARKER}")
536}
537
538pub fn is_spar_comment(body: &str) -> bool {
540 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
541}
542
543fn has_exact_comment(comments: &[Value], body: &str) -> bool {
544 comments.iter().any(|comment| {
545 comment
546 .get("body")
547 .and_then(Value::as_str)
548 .is_some_and(|seen| seen == body)
549 })
550}
551
552fn reconcile_comment_post(
553 number: i64,
554 body: &str,
555 post_error: crate::error::SparError,
556 comments: Result<Vec<Value>>,
557) -> Result<()> {
558 match comments {
559 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
560 Ok(_) => Err(post_error),
561 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
562 "could not comment on #{number}. {} The result could not be verified: {}",
563 post_error.last_line(),
564 read_error.last_line()
565 ))),
566 }
567}
568
569fn reconcile_issue_edit(
570 number: i64,
571 wanted: &str,
572 edit_error: crate::error::SparError,
573 observed: Result<String>,
574) -> Result<()> {
575 match observed {
576 Ok(body) if body == wanted => Ok(()),
577 Ok(_) => Err(spar_err!(
578 "could not rewrite the body of #{number}. {}",
579 edit_error.last_line()
580 )),
581 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
582 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
583 edit_error.last_line(),
584 read_error.last_line()
585 ))),
586 }
587}
588
589fn issue_url_has_number(url: &str) -> bool {
590 url.trim()
591 .rsplit('/')
592 .next()
593 .and_then(|tail| tail.parse::<i64>().ok())
594 .is_some_and(|number| number > 0)
595}
596
597fn reconcile_issue_creation(
598 title: &str,
599 created: Result<String>,
600 found: Result<Option<ExistingIssue>>,
601) -> Result<String> {
602 match (created, found) {
603 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
604 (_, Ok(Some(issue))) => Ok(issue.url),
605 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
606 "issue creation reported success but no matching issue was found for {title:?}"
607 ))),
608 (Err(create), Ok(None)) => Err(spar_err!(
609 "could not file issue {title:?}. {}",
610 create.last_line()
611 )),
612 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
613 "issue creation reported success for {title:?}, but it could not be verified. {}",
614 check.last_line()
615 ))),
616 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
617 "could not file issue {title:?}. {} The result could not be verified: {}",
618 create.last_line(),
619 check.last_line()
620 ))),
621 }
622}
623
624fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
625 if output.trim().is_empty() {
626 return Ok(None);
627 }
628 for line in output.lines() {
629 let mut fields = line.split_whitespace();
630 let oid = fields.next().unwrap_or_default();
631 let name = fields.next().unwrap_or_default();
632 if name == remote_ref && !oid.is_empty() {
633 return Ok(Some(oid.to_string()));
634 }
635 }
636 Err(spar_err!(
637 "origin returned an unexpected ref listing for {remote_ref}"
638 ))
639}
640
641fn reconcile_failed_split_push(
642 branch: &str,
643 push_error: crate::error::SparError,
644 local: Result<String>,
645 remote: Result<String>,
646) -> std::result::Result<(), SplitPushError> {
647 let remote_ref = format!("refs/heads/{branch}");
648 match (local, remote) {
649 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
650 Ok(Some(oid)) if oid == local.trim() => Ok(()),
651 Ok(_) => Err(SplitPushError::new(
652 format!(
653 "could not create origin/{branch}. {} The remote branch is absent or points \
654 somewhere else. Nothing was overwritten.",
655 push_error.last_line()
656 ),
657 false,
658 )),
659 Err(check) => Err(SplitPushError::new(
660 format!(
661 "could not confirm whether origin/{branch} was created. {} The remote result \
662 could not be verified: {}",
663 push_error.last_line(),
664 check.last_line()
665 ),
666 true,
667 )),
668 },
669 (local, remote) => {
670 let check = match (local, remote) {
671 (Err(local), Err(remote)) => format!(
672 "the local commit could not be read: {}; origin could not be read: {}",
673 local.last_line(),
674 remote.last_line()
675 ),
676 (Err(local), _) => {
677 format!("the local commit could not be read: {}", local.last_line())
678 }
679 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
680 _ => unreachable!(),
681 };
682 Err(SplitPushError::new(
683 format!(
684 "could not confirm whether origin/{branch} was created. {} The result could \
685 not be verified because {check}",
686 push_error.last_line()
687 ),
688 true,
689 ))
690 }
691 }
692}
693
694impl Repo {
695 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
696 let root =
697 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
698 let inside = proc::run_str(
701 &["git", "rev-parse", "--is-inside-work-tree"],
702 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
703 )
704 .unwrap_or_default();
705 if inside.trim() != "true" {
706 bail!("not a git repository: {}", root.display());
707 }
708 let repo = Self {
709 root,
710 style: cfg.style.clone(),
711 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
712 state_store: cfg.loop_cfg.state_store,
713 followups: cfg.loop_cfg.followups,
714 drafts: cfg.loop_cfg.drafts,
715 viewer: OnceLock::new(),
716 checkpoints: Mutex::new(BTreeMap::new()),
717 writes: WriteStats::default(),
718 };
719 repo.self_exclude();
720 Ok(repo)
721 }
722
723 fn self_exclude(&self) {
731 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
732 let git_dir = git_dir.trim();
733 if git_dir.is_empty() {
734 return;
735 }
736 let path = Path::new(git_dir).join("info").join("exclude");
737 let existing = std::fs::read_to_string(&path).unwrap_or_default();
738
739 let wanted = [
744 format!("/{WORKTREE_DIR}/"),
745 format!("/{STATE_DIR}/"),
746 format!("{RECOVERY_MARKER_PREFIX}*"),
747 ];
748 let missing: Vec<&String> = wanted
749 .iter()
750 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
751 .collect();
752 if missing.is_empty() {
753 return;
754 }
755
756 use std::io::Write;
757 if let Some(parent) = path.parent() {
758 let _ = std::fs::create_dir_all(parent);
759 }
760 let mut block = String::new();
761 if !existing.is_empty() && !existing.ends_with('\n') {
762 block.push('\n');
763 }
764 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
765 for line in missing {
766 block.push_str(line);
767 block.push('\n');
768 }
769 if let Ok(mut file) = std::fs::OpenOptions::new()
770 .create(true)
771 .append(true)
772 .open(&path)
773 {
774 let _ = file.write_all(block.as_bytes());
775 }
776 }
777
778 pub fn root(&self) -> &Path {
779 &self.root
780 }
781
782 pub(crate) fn write_summary(&self) -> WriteSummary {
783 WriteSummary {
784 attempted: self.writes.attempted.load(Ordering::Relaxed),
785 failed: self.writes.failed.load(Ordering::Relaxed),
786 }
787 }
788
789 pub(crate) fn record_write<T, E>(
790 &self,
791 result: std::result::Result<T, E>,
792 ) -> std::result::Result<T, E> {
793 self.record_write_outcome(result.is_err());
794 result
795 }
796
797 pub(crate) fn record_failed_write<T, E>(
798 &self,
799 result: std::result::Result<T, E>,
800 ) -> std::result::Result<T, E> {
801 if result.is_err() {
802 self.record_write_outcome(true);
803 }
804 result
805 }
806
807 fn record_write_outcome(&self, failed: bool) {
808 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
809 if failed {
810 self.writes.failed.fetch_add(1, Ordering::Relaxed);
811 }
812 }
813
814 pub fn clean(&self, text: &str) -> Result<String> {
820 let out = style::scrub(text, &self.style);
821 let bad = style::violations(&out, &self.style);
822 if !bad.is_empty() {
823 bail!(
824 "style gate could not clean text ({}): {}",
825 bad.join(", "),
826 style::clip(&out, 300)
827 );
828 }
829 Ok(out)
830 }
831
832 pub fn clean_body(&self, text: &str) -> Result<String> {
834 self.clean(&style::body(text, &self.style))
835 }
836
837 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
839 self.clean(&style::issue_body(text, &self.style))
840 }
841
842 pub fn clean_title(&self, text: &str) -> Result<String> {
853 Ok(style::title(&self.clean(text)?, &self.style))
854 }
855
856 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
857 let title = self.record_failed_write(self.clean_title(text))?;
858 if title.trim().is_empty() {
859 return self.record_failed_write(Err(spar_err!(
860 "nothing left of the title after cleaning it"
861 )));
862 }
863 Ok(title)
864 }
865
866 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
867 if self.followups == Followups::Issues {
868 self.clean_nonempty_title_for_write(text)
869 } else {
870 self.clean_title(text)
871 }
872 }
873
874 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
877 ExecOpts::new()
878 .cwd(cwd.unwrap_or(&self.root))
879 .check(check)
880 .timeout_secs(600)
881 }
882
883 pub fn git(&self, args: &[&str]) -> Result<String> {
884 self.git_at(None, args)
885 }
886
887 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
888 let argv = git_without_maintenance_argv(args);
889 proc::run(&argv, &self.git_opts(cwd, true))
890 }
891
892 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
898 let argv = git_without_automation_argv(args);
899 proc::run(
900 &argv,
901 &self.git_opts(Some(cwd), true).stop_descendants(true),
902 )
903 }
904
905 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
906 let argv = git_without_automation_argv(args);
907 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
908 .map(|output| output.ok())
909 }
910
911 pub fn git_try(&self, args: &[&str]) -> String {
913 self.git_try_at(None, args)
914 }
915
916 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
917 let argv = git_without_maintenance_argv(args);
918 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
919 }
920
921 pub fn default_branch(&self, configured: &str) -> String {
924 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
925 match refname.trim().rsplit('/').next() {
926 Some(name) if !name.is_empty() => name.to_string(),
927 _ => configured.to_string(),
928 }
929 }
930
931 pub fn branch_for_issue(&self, issue: i64) -> String {
939 format!("{}issue-{issue}", self.branch_prefix)
940 }
941
942 pub fn branch_for_pr(&self, number: i64) -> String {
943 format!("{}pr-{number}", self.branch_prefix)
944 }
945
946 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
955 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
956 }
957
958 fn ledger_path(&self) -> PathBuf {
959 self.root.join(STATE_DIR).join("branches.json")
960 }
961
962 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
963 std::fs::read_to_string(self.ledger_path())
964 .ok()
965 .and_then(|text| serde_json::from_str(&text).ok())
966 .unwrap_or_default()
967 }
968
969 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
970 let mut data = self.known_branches();
971 data.insert(
972 branch.to_string(),
973 BranchRecord {
974 kind: kind.to_string(),
975 number,
976 },
977 );
978 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
979 logdim!("could not record branch {branch}: {e}");
980 }
981 }
982
983 pub fn forget_branch(&self, branch: &str) {
984 let mut data = self.known_branches();
985 if data.remove(branch).is_none() {
986 return;
987 }
988 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
989 logdim!("could not update the branch record: {e}");
990 }
991 }
992
993 fn worktree_path(&self, name: &str) -> PathBuf {
996 self.root.join(WORKTREE_DIR).join(name)
997 }
998
999 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1001 let branch = self.branch_for_issue(issue);
1002 let path = self.worktree_path(&format!("issue-{issue}"));
1003
1004 self.refuse_issue_branch_rebuild(issue, base)?;
1005 self.refuse_dirty_worktree(
1006 &path,
1007 &format!("worktree for issue #{issue}"),
1008 Disposable::BuildOutput,
1009 )?;
1010
1011 if !self.branch_deletion_is_safe(&branch)? {
1012 bail!(
1013 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1014 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1015 before retrying."
1016 );
1017 }
1018
1019 if !self.remove_worktree_at(&path)? {
1020 bail!(
1021 "the existing worktree for issue #{issue} could not be removed safely. Its \
1022 branch was kept."
1023 );
1024 }
1025 if !self.delete_branch_if_safe(&branch)? {
1026 bail!(
1027 "the existing branch {branch} changed or remained checked out while its \
1028 worktree was being rebuilt. It was kept."
1029 );
1030 }
1031
1032 if let Some(parent) = path.parent() {
1033 std::fs::create_dir_all(parent)
1034 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1035 }
1036
1037 let path_str = path.display().to_string();
1038 let remote_start = format!("origin/{base}");
1039 let created = self
1040 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1041 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1042
1043 created.map_err(|e| {
1046 spar_err!(
1047 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1048 and does `origin` exist?",
1049 e.last_line()
1050 )
1051 })?;
1052 self.record_branch(&branch, "issue", issue);
1053 Ok((path, branch))
1054 }
1055
1056 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1063 let branch = self.branch_for_issue(issue);
1064 let base_remote_ref = format!("refs/heads/{base}");
1065 let base_tracking_ref = format!("refs/remotes/origin/{base}");
1066 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1067 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1068 .map_err(|e| {
1069 spar_err!(
1070 "could not refresh origin/{base} before checking issue #{issue}: {}",
1071 e.last_line()
1072 )
1073 })?;
1074
1075 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1076 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1077 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1078 bail!(
1079 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1080 pull request accounts for them. Rebuilding it would force push over that \
1081 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1082 it, or delete it with `git push origin --delete {branch}` if the remote \
1083 branch is no longer needed."
1084 );
1085 }
1086 }
1087
1088 let local_ref = format!("refs/heads/{branch}");
1089 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1090 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1091 let recorded_pr = self
1092 .known_branches()
1093 .get(&branch)
1094 .is_some_and(|record| record.kind == "pr");
1095 let preserved = ahead == 0
1096 || if recorded_pr {
1097 self.local_branch_is_preserved(&branch)?
1098 } else {
1099 self.pull_request_holds(&branch, &local_ref, base)
1100 };
1101 if !preserved {
1102 let listed = self
1103 .commit_lines(&self.root, &local_ref, base)
1104 .iter()
1105 .map(|line| format!(" {line}"))
1106 .collect::<Vec<_>>()
1107 .join("\n");
1108 bail!(
1109 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1110 no pull request preserves them. Rebuilding it would delete the only copy.\n\
1111 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1112 it, or delete it with `git branch -D {branch}` if it is stale."
1113 );
1114 }
1115 }
1116 Ok(())
1117 }
1118
1119 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1120 let live_ref = format!("refs/heads/{branch}");
1121 let tracking_ref = format!("refs/remotes/origin/{branch}");
1122 let listed = self
1123 .git(&["ls-remote", "--heads", "origin", &live_ref])
1124 .map_err(|e| {
1125 spar_err!(
1126 "could not verify whether origin/{branch} still exists: {}",
1127 e.last_line()
1128 )
1129 })?;
1130
1131 if remote_head_oid(&listed, &live_ref)?.is_some() {
1132 let refspec = format!("+{live_ref}:{tracking_ref}");
1133 self.git(&["fetch", "--no-tags", "origin", &refspec])
1134 .map_err(|e| {
1135 spar_err!(
1136 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1137 e.last_line()
1138 )
1139 })?;
1140 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1141 bail!("origin/{branch} was fetched but its tracking ref is missing");
1142 }
1143 return Ok(Some(tracking_ref));
1144 }
1145
1146 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1147 return Ok(None);
1148 }
1149 let expected = self
1150 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1151 .trim()
1152 .to_string();
1153 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1154 .map_err(|e| {
1155 spar_err!(
1156 "could not discard stale origin/{branch} tracking ref safely: {}",
1157 e.last_line()
1158 )
1159 })?;
1160 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1161 bail!(
1162 "origin/{branch} changed while its stale tracking ref was being removed. It was \
1163 kept."
1164 );
1165 }
1166 Ok(None)
1167 }
1168
1169 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1178 self.prs_for_branch(branch)
1179 .iter()
1180 .any(|pr| self.pr_head_holds(pr.number, refname, base))
1181 }
1182
1183 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1184 let head = format!("refs/spar/pr-head/{number}");
1185 let refspec = format!("+refs/pull/{number}/head:{head}");
1186 if self.git(&["fetch", "origin", &refspec]).is_err() {
1187 return false;
1188 }
1189 let held = self.commits_held_by(refname, base, &head);
1190 self.git_try(&["update-ref", "-d", &head]);
1191 held
1192 }
1193
1194 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1195 let argv = vec![
1196 "git".to_string(),
1197 "merge-base".to_string(),
1198 "--is-ancestor".to_string(),
1199 older.to_string(),
1200 newer.to_string(),
1201 ];
1202 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1203 match out.code {
1204 0 => Ok(true),
1205 1 => Ok(false),
1206 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1207 }
1208 }
1209
1210 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1211 let head = format!("refs/spar/pr-head/{number}");
1212 let refspec = format!("+refs/pull/{number}/head:{head}");
1213 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1214 spar_err!(
1215 "could not verify the immutable head of PR #{number}: {}",
1216 e.last_line()
1217 )
1218 })?;
1219 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1220 self.git_try(&["update-ref", "-d", &head]);
1221 held
1222 }
1223
1224 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1225 let text = self.gh(&[
1226 "pr",
1227 "list",
1228 "--head",
1229 branch,
1230 "--state",
1231 "all",
1232 "--json",
1233 "number,url,title",
1234 ])?;
1235 serde_json::from_str(text.trim())
1236 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1237 }
1238
1239 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1240 let branch_ref = format!("refs/heads/{branch}");
1241 if record.kind == "pr" {
1242 return self.pr_head_contains_checked(record.number, &branch_ref);
1243 }
1244 let prs = self.branch_prs_checked(branch)?;
1245 if prs.is_empty() {
1246 return Ok(false);
1247 }
1248 for pr in prs {
1249 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1250 return Ok(true);
1251 }
1252 }
1253 Ok(false)
1254 }
1255
1256 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1257 let local_ref = format!("refs/heads/{branch}");
1258 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1259 return Ok(true);
1260 }
1261 let oid = self
1262 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1263 .trim()
1264 .to_string();
1265 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
1266 if !durable_tip {
1267 let remote_ref = format!("refs/heads/{branch}");
1268 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
1269 durable_tip = remote.lines().any(|line| {
1270 line.split_whitespace()
1271 .next()
1272 .is_some_and(|remote_oid| remote_oid == oid)
1273 });
1274 }
1275 if !durable_tip {
1276 if let Some(record) = self.known_branches().get(branch) {
1277 durable_tip = self.branch_is_preserved_checked(branch, record)?;
1278 }
1279 }
1280 if !durable_tip {
1281 return Ok(false);
1282 }
1283 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1284 }
1285
1286 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
1290 let local_ref = format!("refs/heads/{branch}");
1291 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1292 return Ok(true);
1293 }
1294 let expected = self
1295 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1296 .trim()
1297 .to_string();
1298 if !self.branch_deletion_is_safe(branch)? {
1299 return Ok(false);
1300 }
1301 let checked_out = self
1302 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
1303 .lines()
1304 .any(|line| line == format!("branch {local_ref}"));
1305 if checked_out {
1306 return Ok(false);
1307 }
1308 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1309 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1310 }
1311
1312 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
1313 let local_ref = review_ref(number);
1314 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1315 return Ok(true);
1316 }
1317 let oid = self
1318 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1319 .trim()
1320 .to_string();
1321 if !self.pr_head_contains_checked(number, &local_ref)? {
1322 return Ok(false);
1323 }
1324 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1325 }
1326
1327 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
1328 let local_ref = review_ref(number);
1329 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1330 return Ok(true);
1331 }
1332 let expected = self
1333 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1334 .trim()
1335 .to_string();
1336 if !self.review_ref_deletion_is_safe(number)? {
1337 return Ok(false);
1338 }
1339 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1340 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1341 }
1342
1343 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
1347 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
1348 self.git_try(&["rev-list", "--count", &range, "--not", other])
1349 .trim()
1350 == "0"
1351 }
1352
1353 pub fn worktree_remove(&self, issue: i64) -> bool {
1354 let path = self.worktree_path(&format!("issue-{issue}"));
1355 match self.remove_worktree_at(&path) {
1356 Ok(removed) => removed,
1357 Err(error) => {
1358 logdim!(
1359 "kept {} because removal did not reach a confirmed quiet point: {}",
1360 path.display(),
1361 error.last_line()
1362 );
1363 false
1364 }
1365 }
1366 }
1367
1368 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
1373 let wanted = std::fs::canonicalize(path)
1374 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
1375 if wanted != path {
1380 return Ok(false);
1381 }
1382 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
1383 let raw = PathBuf::from(value.trim());
1384 let joined = if raw.is_absolute() {
1385 raw
1386 } else {
1387 cwd.join(raw)
1388 };
1389 std::fs::canonicalize(&joined)
1390 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
1391 };
1392 let expected =
1393 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
1394 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
1395 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
1396 let expected = resolve(&self.root, &expected)?;
1397 let actual = resolve(path, &actual)?;
1398 let top = resolve(path, &top)?;
1399 Ok(expected == actual && top == wanted)
1400 }
1401
1402 fn remove_worktree_at_with_force(
1408 &self,
1409 path: &Path,
1410 force: bool,
1411 disposable: Disposable,
1412 ) -> Result<bool> {
1413 let existed = path.exists();
1414 if path.exists() {
1415 match self.worktree_belongs_to_repo(path) {
1416 Ok(true) => {}
1417 Ok(false) => {
1418 logdim!(
1419 "kept {} because it is not a worktree owned by this repository",
1420 path.display()
1421 );
1422 return Ok(false);
1423 }
1424 Err(e) => {
1425 logdim!(
1426 "kept {} because its worktree ownership could not be verified: {}",
1427 path.display(),
1428 e.last_line()
1429 );
1430 return Ok(false);
1431 }
1432 }
1433 if !force {
1434 match self.has_recoverable_work(path, disposable) {
1435 Ok(true) => {
1436 logdim!(
1437 "kept {} because it contains recoverable files or repository state",
1438 path.display()
1439 );
1440 return Ok(false);
1441 }
1442 Err(e) => {
1443 logdim!(
1444 "kept {} because its recoverable state could not be checked: {}",
1445 path.display(),
1446 e.last_line()
1447 );
1448 return Ok(false);
1449 }
1450 Ok(false) => {}
1451 }
1452 }
1453 }
1454 let path_str = path.display().to_string();
1460 let command_ok = if force || disposable == Disposable::ReviewOutput {
1461 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
1462 } else {
1463 self.git_try_without_automation(&["worktree", "remove", &path_str])?
1464 };
1465 Ok((command_ok || !existed) && !path.exists())
1466 }
1467
1468 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
1469 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
1470 }
1471
1472 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
1474 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
1475 }
1476
1477 fn remove_worktree_at_force(&self, path: &Path) -> bool {
1479 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
1480 Ok(removed) => removed,
1481 Err(error) => {
1482 logdim!(
1483 "kept {} because removal did not reach a confirmed quiet point: {}",
1484 path.display(),
1485 error.last_line()
1486 );
1487 false
1488 }
1489 }
1490 }
1491
1492 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
1497 if path.exists() && !self.worktree_belongs_to_repo(path)? {
1498 bail!(
1499 "{} is not a worktree owned by this repository, so it was kept",
1500 path.display()
1501 );
1502 }
1503 if path.exists() && self.has_recoverable_work(path, disposable)? {
1504 bail!(
1505 "the verified worktree at {} contains recoverable files or repository state. It \
1506 was kept.",
1507 path.display()
1508 );
1509 }
1510 let path_str = path.display().to_string();
1511 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
1512 .map_err(|e| {
1513 e.with_message(format!(
1514 "could not remove the verified worktree at {}: {}. It was kept.",
1515 path.display(),
1516 e.last_line()
1517 ))
1518 })?;
1519 Ok(!path.exists())
1520 }
1521
1522 fn refuse_dirty_worktree(
1523 &self,
1524 path: &Path,
1525 label: &str,
1526 disposable: Disposable,
1527 ) -> Result<()> {
1528 if !path.is_dir() {
1529 return Ok(());
1530 }
1531 let has_files = std::fs::read_dir(path)
1532 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
1533 .next()
1534 .is_some();
1535 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
1536 spar_err!(
1537 "could not verify whether the existing {label} at {} belongs to this repository, \
1538 so it was kept: {}",
1539 path.display(),
1540 e.last_line()
1541 )
1542 })?;
1543 if !owned {
1544 if has_files {
1545 bail!(
1546 "the existing {label} at {} is not a worktree owned by \
1547 this repository. Refusing to remove it.",
1548 path.display()
1549 );
1550 }
1551 return Ok(());
1552 }
1553 if !path.join(".git").exists() {
1554 if has_files {
1555 bail!(
1556 "the existing {label} at {} is not a readable Git worktree and is not empty. \
1557 Refusing to remove it.",
1558 path.display()
1559 );
1560 }
1561 return Ok(());
1562 }
1563 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
1564 spar_err!(
1565 "could not verify whether the existing {label} at {} is clean, so it was kept: \
1566 {}",
1567 path.display(),
1568 e.last_line()
1569 )
1570 })?;
1571 if dirty {
1572 bail!(
1573 "the existing {label} contains uncommitted changes or ignored files at {}. \
1574 Rebuilding it would delete those files.\nCommit or recover them before running this \
1575 command again, or use `spar clean --all` if they are not needed.",
1576 path.display()
1577 );
1578 }
1579 Ok(())
1580 }
1581
1582 pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
1584 let head = pr.head_ref_name.clone();
1585 if head.trim().is_empty() {
1586 bail!("PR #{} has no head branch to check out", pr.number);
1587 }
1588 let path = self.worktree_path(&format!("pr-{}", pr.number));
1589 let local = self.branch_for_pr(pr.number);
1590
1591 self.git(&["fetch", "origin", &head]).map_err(|e| {
1592 spar_err!(
1593 "could not fetch the branch behind PR #{}: {}",
1594 pr.number,
1595 e.last_line()
1596 )
1597 })?;
1598 let start = format!("origin/{head}");
1599 let start_ref = format!("refs/remotes/origin/{head}");
1600 let local_ref = format!("refs/heads/{local}");
1601 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1602 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
1603 if unpushed > 0 {
1604 bail!(
1605 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
1606 on {start}. Rebuilding it would delete their branch.\nInspect the worktree at \
1607 {} and push or recover those commits before running this command again.",
1608 pr.number,
1609 path.display()
1610 );
1611 }
1612 }
1613 self.refuse_dirty_worktree(
1614 &path,
1615 &format!("worktree for PR #{}", pr.number),
1616 Disposable::BuildOutput,
1617 )?;
1618 if !self.branch_deletion_is_safe(&local)? {
1619 bail!(
1620 "the existing branch {local} has a tip or reflog-only commit that no surviving \
1621 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1622 before retrying."
1623 );
1624 }
1625 if !self.remove_worktree_at(&path)? {
1626 bail!(
1627 "the existing worktree for PR #{} could not be removed safely. Its branch was \
1628 kept.",
1629 pr.number
1630 );
1631 }
1632 if !self.delete_branch_if_safe(&local)? {
1633 bail!(
1634 "the existing branch {local} changed or remained checked out while the PR \
1635 worktree was being rebuilt. It was kept."
1636 );
1637 }
1638
1639 let path_str = path.display().to_string();
1640 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
1641 self.record_branch(&local, "pr", pr.number);
1642 Ok((path, head))
1643 }
1644
1645 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
1655 let path = self.worktree_path(&format!("review-{number}"));
1656 let local_ref = review_ref(number);
1657 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
1658
1659 self.refuse_review_worktree_changes(number)?;
1660
1661 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1662 spar_err!(
1663 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
1664 every pull request, so this usually means the number is wrong or `origin` does \
1665 not point at the repository the PR is on.",
1666 e.last_line()
1667 )
1668 })?;
1669
1670 if let Some(parent) = path.parent() {
1671 std::fs::create_dir_all(parent)
1672 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1673 }
1674 if !self.remove_review_worktree_at(&path)? {
1675 bail!(
1676 "the existing review worktree for PR #{number} could not be removed safely. Its \
1677 reference was kept."
1678 );
1679 }
1680 let path_str = path.display().to_string();
1681 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
1682 Ok(path)
1683 }
1684
1685 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
1686 let path = self.worktree_path(&format!("review-{number}"));
1687 if !path.is_dir() {
1688 return Ok(());
1689 }
1690 let local_ref = review_ref(number);
1691 if !self.worktree_belongs_to_repo(&path)? {
1692 return Ok(());
1693 }
1694 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1695 bail!(
1696 "the existing review worktree for PR #{number} has no recorded head at \
1697 {local_ref}. Refusing to rebuild {}.",
1698 path.display()
1699 );
1700 }
1701 let worktree_head = self.head_oid_checked(&path)?;
1702 let recorded_head = self
1703 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1704 .trim()
1705 .to_string();
1706 if worktree_head != recorded_head {
1707 bail!(
1708 "the existing review worktree for PR #{number} has a local commit that is not on \
1709 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
1710 {} before retrying.",
1711 path.display()
1712 );
1713 }
1714 self.report_recovery_markers(&path);
1715 self.refuse_dirty_worktree(
1716 &path,
1717 &format!("review worktree for PR #{number}"),
1718 Disposable::ReviewOutput,
1719 )?;
1720 Ok(())
1721 }
1722
1723 fn report_recovery_markers(&self, path: &Path) {
1731 let Ok(entries) = std::fs::read_dir(path) else {
1732 return;
1733 };
1734 for entry in entries.flatten() {
1735 let name = entry.file_name();
1736 let Some(name) = name.to_str() else { continue };
1737 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
1738 continue;
1739 }
1740 match std::fs::read_to_string(entry.path()) {
1741 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
1742 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
1743 }
1744 }
1745 }
1746
1747 pub fn worktree_for_split(
1760 &self,
1761 parent: i64,
1762 index: usize,
1763 start: &str,
1764 ) -> Result<(PathBuf, String)> {
1765 let slot = self.free_split_slot(parent, index)?;
1766 let branch = format!("{}{slot}", self.branch_prefix);
1767 let path = self.worktree_path(&slot);
1768
1769 if let Some(dir) = path.parent() {
1770 std::fs::create_dir_all(dir)
1771 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
1772 }
1773 self.refuse_dirty_worktree(
1774 &path,
1775 &format!("worktree for part {index} of PR #{parent}"),
1776 Disposable::BuildOutput,
1777 )?;
1778 if !self.remove_worktree_at(&path)? {
1782 bail!(
1783 "the existing worktree for part {index} of PR #{parent} could not be removed \
1784 safely. No branch was created."
1785 );
1786 }
1787
1788 let path_str = path.display().to_string();
1789 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
1790 .map_err(|e| {
1791 spar_err!(
1792 "could not create a worktree for part {index} of #{parent}. {}",
1793 e.last_line()
1794 )
1795 })?;
1796 self.record_branch(&branch, "split", parent);
1799 Ok((path, branch))
1800 }
1801
1802 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
1809 for attempt in 1..=SPLIT_SLOTS {
1810 let slot = split_slot(parent, index, attempt);
1811 let branch = format!("{}{slot}", self.branch_prefix);
1812 self.git_try(&["fetch", "origin", &branch]);
1813 if !self.rev_exists(&self.root, &branch)
1814 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
1815 {
1816 return Ok(slot);
1817 }
1818 }
1819 bail!(
1820 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
1821 names are all taken. Inspect the existing branches and child pull requests. Finish \
1822 recording the earlier split, or remove every retained local worktree and branch, \
1823 child pull request, and remote split branch before starting over.",
1824 self.branch_for_split(parent, index)
1825 )
1826 }
1827
1828 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
1834 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
1835 Ok(!self
1836 .git(&["ls-remote", "--heads", "origin", &pattern])?
1837 .trim()
1838 .is_empty())
1839 }
1840
1841 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
1848 match self.branch_deletion_is_safe(branch) {
1849 Ok(true) => {}
1850 Ok(false) => {
1851 logdim!(
1852 "kept {branch} and {} because no surviving ref preserves its tip",
1853 dir.display()
1854 );
1855 return;
1856 }
1857 Err(error) => {
1858 logdim!(
1859 "kept {branch} and {} because preservation could not be verified: {}",
1860 dir.display(),
1861 error.last_line()
1862 );
1863 return;
1864 }
1865 }
1866 match self.remove_worktree_at(dir) {
1867 Ok(true) => match self.delete_branch_if_safe(branch) {
1868 Ok(true) => self.forget_branch(branch),
1869 Ok(false) => {
1870 logdim!("kept {branch} because its tip or reflog changed before deletion")
1871 }
1872 Err(error) => logdim!(
1873 "kept {branch} because deletion safety could not be rechecked: {}",
1874 error.last_line()
1875 ),
1876 },
1877 Ok(false) => {}
1878 Err(error) => logdim!(
1879 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
1880 dir.display(),
1881 error.last_line()
1882 ),
1883 }
1884 }
1885
1886 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
1892 let record = self.known_branches().get(branch).cloned();
1893 if record.is_none_or(|record| record.kind != "split") {
1894 logdim!("kept {branch} because no split branch record proves ownership");
1895 return false;
1896 }
1897 let local_ref = format!("refs/heads/{branch}");
1898 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
1899 Ok(value) => value.trim().to_string(),
1900 Err(error) => {
1901 logdim!(
1902 "kept {branch} because its tip could not be checked: {}",
1903 error.last_line()
1904 );
1905 return false;
1906 }
1907 };
1908 if expected != disposable_head {
1909 logdim!("kept {branch} because it moved beyond the disposable slice");
1910 return false;
1911 }
1912 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
1913 Ok(true) => {}
1914 Ok(false) => {
1915 logdim!(
1916 "kept {branch} because its reflog contains work outside the disposable slice"
1917 );
1918 return false;
1919 }
1920 Err(error) => {
1921 logdim!(
1922 "kept {branch} because its reflog could not be checked: {}",
1923 error.last_line()
1924 );
1925 return false;
1926 }
1927 }
1928 match self.head_oid_checked(dir) {
1929 Ok(head) if head == disposable_head => {}
1930 Ok(_) => {
1931 logdim!(
1932 "kept {branch} and {} because the worktree moved beyond the disposable slice",
1933 dir.display()
1934 );
1935 return false;
1936 }
1937 Err(error) => {
1938 logdim!(
1939 "kept {branch} and {} because its head could not be checked: {}",
1940 dir.display(),
1941 error.last_line()
1942 );
1943 return false;
1944 }
1945 }
1946 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
1947 Ok(true) => {}
1948 Ok(false) => return false,
1949 Err(error) => {
1950 logdim!(
1951 "kept {branch} and {} because the disposable slice could not be verified: {}",
1952 dir.display(),
1953 error.last_line()
1954 );
1955 return false;
1956 }
1957 }
1958 if let Err(error) =
1959 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
1960 {
1961 logdim!(
1962 "kept {branch} because its exact disposable tip could not be deleted: {}",
1963 error.last_line()
1964 );
1965 return false;
1966 }
1967 match self.exact_ref_exists_checked(&self.root, &local_ref) {
1968 Ok(false) => {
1969 self.forget_branch(branch);
1970 true
1971 }
1972 Ok(true) => {
1973 logdim!("kept {branch} because its ref still exists after deletion");
1974 false
1975 }
1976 Err(error) => {
1977 logdim!(
1978 "kept the branch record for {branch} because deletion could not be verified: {}",
1979 error.last_line()
1980 );
1981 false
1982 }
1983 }
1984 }
1985
1986 pub fn release_review_worktree(&self, number: i64) {
1987 let path = self.worktree_path(&format!("review-{number}"));
1988 match self.review_ref_deletion_is_safe(number) {
1989 Ok(true) => {}
1990 Ok(false) => {
1991 logdim!(
1992 "kept {} because no surviving ref preserves its review history",
1993 path.display()
1994 );
1995 return;
1996 }
1997 Err(error) => {
1998 logdim!(
1999 "kept {} because review history could not be verified: {}",
2000 path.display(),
2001 error.last_line()
2002 );
2003 return;
2004 }
2005 }
2006 match self.remove_worktree_at(&path) {
2007 Ok(true) => match self.delete_review_ref_if_safe(number) {
2008 Ok(true) => {}
2009 Ok(false) => logdim!(
2010 "kept {} because its review history changed before deletion",
2011 review_ref(number)
2012 ),
2013 Err(error) => logdim!(
2014 "kept {} because deletion safety could not be rechecked: {}",
2015 review_ref(number),
2016 error.last_line()
2017 ),
2018 },
2019 Ok(false) => {}
2020 Err(error) => logdim!(
2021 "kept {} because removal did not reach a confirmed quiet point: {}",
2022 path.display(),
2023 error.last_line()
2024 ),
2025 }
2026 }
2027
2028 pub(crate) fn release_review_worktree_checked(
2031 &self,
2032 number: i64,
2033 checkpoint: &WorktreeCheckpoint,
2034 ) -> Result<()> {
2035 let path = self.worktree_path(&format!("review-{number}"));
2036 self.require_unchanged_worktree(
2037 &path,
2038 checkpoint,
2039 &format!("review worktree for PR #{number}"),
2040 )?;
2041 if !self.review_ref_deletion_is_safe(number)? {
2042 bail!(
2043 "the review reference for PR #{number} has reflog-only recovery history. The \
2044 worktree and reference were kept."
2045 );
2046 }
2047 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2048 bail!(
2049 "the verified review worktree at {} could not be removed, so its reference was \
2050 kept",
2051 path.display()
2052 );
2053 }
2054 if !self.delete_review_ref_if_safe(number)? {
2055 bail!(
2056 "the review reference for PR #{number} changed before deletion. The reference was \
2057 kept."
2058 );
2059 }
2060 Ok(())
2061 }
2062
2063 pub fn release_pr_worktree(&self, number: i64) -> bool {
2064 let path = self.worktree_path(&format!("pr-{number}"));
2065 let local = self.branch_for_pr(number);
2066 match self.branch_deletion_is_safe(&local) {
2067 Ok(true) => {}
2068 Ok(false) => {
2069 logdim!(
2070 "kept {local} and {} because no surviving ref preserves its tip",
2071 path.display()
2072 );
2073 return false;
2074 }
2075 Err(error) => {
2076 logdim!(
2077 "kept {local} and {} because preservation could not be verified: {}",
2078 path.display(),
2079 error.last_line()
2080 );
2081 return false;
2082 }
2083 }
2084 match self.remove_worktree_at(&path) {
2085 Ok(true) => match self.delete_branch_if_safe(&local) {
2086 Ok(true) => {
2087 self.forget_branch(&local);
2088 true
2089 }
2090 Ok(false) => {
2091 logdim!("kept {local} because its tip or reflog changed before deletion");
2092 false
2093 }
2094 Err(error) => {
2095 logdim!(
2096 "kept {local} because deletion safety could not be rechecked: {}",
2097 error.last_line()
2098 );
2099 false
2100 }
2101 },
2102 Ok(false) => false,
2103 Err(error) => {
2104 logdim!(
2105 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
2106 path.display(),
2107 error.last_line()
2108 );
2109 false
2110 }
2111 }
2112 }
2113
2114 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
2125 let remote = format!("origin/{base}");
2126 if self.rev_exists(cwd, &remote) {
2127 return remote;
2128 }
2129 if self.rev_exists(cwd, base) {
2130 logdim!("origin/{base} does not resolve, comparing against local {base}");
2131 return base.to_string();
2132 }
2133 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
2134 remote
2135 }
2136
2137 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
2138 let spec = format!("{refname}^{{commit}}");
2139 !self
2140 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
2141 .trim()
2142 .is_empty()
2143 }
2144
2145 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2146 let range = format!("{}..HEAD", self.base_ref(cwd, base));
2147 !self
2148 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2149 .trim()
2150 .is_empty()
2151 }
2152
2153 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2154 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2155 Ok(found.lines().any(|line| line.trim() == refname))
2156 }
2157
2158 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2159 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2160 count.trim().parse::<usize>().map_err(|e| {
2161 spar_err!(
2162 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2163 count.trim()
2164 )
2165 })
2166 }
2167
2168 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2169 let remote = format!("refs/remotes/origin/{base}");
2170 if self.exact_ref_exists_checked(cwd, &remote)? {
2171 return Ok(remote);
2172 }
2173 let local = format!("refs/heads/{base}");
2174 if self.exact_ref_exists_checked(cwd, &local)? {
2175 return Ok(local);
2176 }
2177 bail!("neither origin/{base} nor local branch {base} resolves")
2178 }
2179
2180 pub(crate) fn commit_count_checked(
2181 &self,
2182 cwd: &Path,
2183 refname: &str,
2184 base: &str,
2185 ) -> Result<usize> {
2186 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
2187 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
2188 count.trim().parse::<usize>().map_err(|e| {
2189 spar_err!(
2190 "git returned an invalid commit count for {range}: {:?} ({e})",
2191 count.trim()
2192 )
2193 })
2194 }
2195
2196 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
2197 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
2198 }
2199
2200 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
2201 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
2202 let head = head.trim().to_string();
2203 if head.is_empty() {
2204 bail!("git returned an empty HEAD for {}", cwd.display());
2205 }
2206 Ok(head)
2207 }
2208
2209 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
2214 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
2215 self.local_branch_is_preserved(branch.trim())
2216 }
2217
2218 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
2221 let known = self.known_branches();
2222 let Some(record) = known.get(branch) else {
2223 return Ok(false);
2224 };
2225 self.branch_is_preserved_checked(branch, record)
2226 }
2227
2228 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
2230 has_uncommitted_work(cwd)
2231 }
2232
2233 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
2236 repository_has_recoverable_work_with(cwd, true, disposable)
2237 }
2238
2239 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2241 let attributes = attribute_state(cwd)?;
2242 Ok(WorktreeBaseline {
2243 attributes,
2244 ignored_untracked: ignored_untracked_state(cwd)?,
2245 git_state: safe_git_state(cwd)?,
2246 })
2247 }
2248
2249 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2252 let attributes = attribute_state(cwd)?;
2253 Ok(WorktreeCheckpoint {
2254 path: std::fs::canonicalize(cwd)
2255 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2256 attributes,
2257 git_state: safe_git_state(cwd)?,
2258 ignored_untracked: ignored_untracked_state(cwd)?,
2259 })
2260 }
2261
2262 pub(crate) fn require_unchanged_worktree(
2265 &self,
2266 cwd: &Path,
2267 checkpoint: &WorktreeCheckpoint,
2268 label: &str,
2269 ) -> Result<()> {
2270 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2271 crate::error::SparError::uncertain_write(format!(
2272 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2273 cwd.display()
2274 ))
2275 })?;
2276 if resolved != checkpoint.path {
2277 return Err(uncertain_worktree_change(
2278 cwd,
2279 format!(
2280 "the {label} moved from {} to {} during inspection. It was kept.",
2281 checkpoint.path.display(),
2282 resolved.display()
2283 ),
2284 ));
2285 }
2286 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
2287 uncertain_worktree_change(
2288 cwd,
2289 format!(
2290 "could not verify attribute files in the {label} at {}: {}. It was kept.",
2291 cwd.display(),
2292 e.last_line()
2293 ),
2294 )
2295 })?;
2296 if attributes != checkpoint.attributes {
2297 return Err(uncertain_worktree_change(
2298 cwd,
2299 format!(
2300 "attribute files in the {label} at {} changed during inspection. It was \
2301 kept for recovery.",
2302 cwd.display()
2303 ),
2304 ));
2305 }
2306 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
2307 uncertain_worktree_change(
2308 cwd,
2309 format!(
2310 "could not verify the Git state of the {label} at {}: {}. It was kept.",
2311 cwd.display(),
2312 e.last_line()
2313 ),
2314 )
2315 })?;
2316 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
2317 uncertain_worktree_change(
2318 cwd,
2319 format!(
2320 "could not verify untracked files in the {label} at {}: {}. It was kept.",
2321 cwd.display(),
2322 e.last_line()
2323 ),
2324 )
2325 })?;
2326 if git_state != checkpoint.git_state
2327 || checkpoint
2328 .ignored_untracked
2329 .changed_beyond_ignored(&ignored)
2330 {
2331 return Err(uncertain_worktree_change(
2332 cwd,
2333 format!(
2334 "the {label} at {} changed during a read-only inspection. It was kept for \
2335 recovery.",
2336 cwd.display()
2337 ),
2338 ));
2339 }
2340 Ok(())
2341 }
2342
2343 pub(crate) fn refuse_new_ignored_files(
2351 &self,
2352 cwd: &Path,
2353 baseline: &WorktreeBaseline,
2354 ) -> Result<()> {
2355 self.check_new_ignored_files(cwd, baseline).map(drop)
2356 }
2357
2358 fn allow_generated_ignored_files(
2361 &self,
2362 cwd: &Path,
2363 baseline: &WorktreeBaseline,
2364 ) -> Result<Vec<PathBuf>> {
2365 self.check_new_ignored_files(cwd, baseline)
2366 }
2367
2368 fn check_new_ignored_files(
2369 &self,
2370 cwd: &Path,
2371 baseline: &WorktreeBaseline,
2372 ) -> Result<Vec<PathBuf>> {
2373 self.refuse_changed_attributes(cwd, baseline)?;
2374 let after = ignored_untracked_state(cwd).map_err(|e| {
2375 uncertain_worktree_change(
2376 cwd,
2377 format!(
2378 "could not verify untracked files in {} after editing: {}. The worktree was \
2379 kept for recovery.",
2380 cwd.display(),
2381 e.last_line()
2382 ),
2383 )
2384 })?;
2385 let changed = baseline.ignored_untracked.changed_paths(&after);
2386 if changed.is_empty() {
2387 return Ok(Vec::new());
2388 }
2389 let (generated, changed): (Vec<_>, Vec<_>) = changed
2390 .into_iter()
2391 .partition(|path| after.is_ignored(path) && is_generated_artifact(path));
2392 if changed.is_empty() {
2393 return Ok(generated);
2394 }
2395 let mut listed = changed
2396 .iter()
2397 .take(5)
2398 .map(|path| format!("{:?}", path.as_os_str()))
2399 .collect::<Vec<_>>()
2400 .join(", ");
2401 if changed.len() > 5 {
2402 listed.push_str(&format!(", and {} more", changed.len() - 5));
2403 }
2404 Err(uncertain_worktree_change(
2405 cwd,
2406 format!(
2407 "the editing call created or changed untracked or ignored file(s) in {} that \
2408 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2409 recovery.",
2410 cwd.display()
2411 ),
2412 ))
2413 }
2414
2415 pub(crate) fn refuse_changed_existing_untracked(
2421 &self,
2422 cwd: &Path,
2423 baseline: &WorktreeBaseline,
2424 ) -> Result<()> {
2425 self.check_changed_existing_untracked(cwd, baseline)
2426 .map(drop)
2427 }
2428
2429 fn allow_changed_generated_artifacts(
2430 &self,
2431 cwd: &Path,
2432 baseline: &WorktreeBaseline,
2433 ) -> Result<Vec<PathBuf>> {
2434 self.check_changed_existing_untracked(cwd, baseline)
2435 }
2436
2437 fn check_changed_existing_untracked(
2438 &self,
2439 cwd: &Path,
2440 baseline: &WorktreeBaseline,
2441 ) -> Result<Vec<PathBuf>> {
2442 self.refuse_changed_attributes(cwd, baseline)?;
2443 let after = ignored_untracked_state(cwd).map_err(|e| {
2444 uncertain_worktree_change(
2445 cwd,
2446 format!(
2447 "could not verify existing untracked files in {} after editing: {}. The \
2448 worktree was kept for recovery.",
2449 cwd.display(),
2450 e.last_line()
2451 ),
2452 )
2453 })?;
2454 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2455 if changed.is_empty() {
2456 return Ok(Vec::new());
2457 }
2458 let (generated, changed): (Vec<_>, Vec<_>) = changed.into_iter().partition(|path| {
2459 baseline.ignored_untracked.is_ignored(path)
2460 && after.is_ignored(path)
2461 && is_generated_artifact(path)
2462 });
2463 if changed.is_empty() {
2464 return Ok(generated);
2465 }
2466 let mut listed = changed
2467 .iter()
2468 .take(5)
2469 .map(|path| format!("{:?}", path.as_os_str()))
2470 .collect::<Vec<_>>()
2471 .join(", ");
2472 if changed.len() > 5 {
2473 listed.push_str(&format!(", and {} more", changed.len() - 5));
2474 }
2475 Err(uncertain_worktree_change(
2476 cwd,
2477 format!(
2478 "the editing call changed or deleted existing untracked file(s) in {}: \
2479 {listed}. The worktree was kept for recovery.",
2480 cwd.display()
2481 ),
2482 ))
2483 }
2484
2485 pub(crate) fn refuse_unrepresented_tracked_changes(
2492 &self,
2493 cwd: &Path,
2494 baseline: &WorktreeBaseline,
2495 ) -> Result<()> {
2496 self.refuse_changed_attributes(cwd, baseline)?;
2497 let after = safe_git_state(cwd).map_err(|e| {
2498 uncertain_worktree_change(
2499 cwd,
2500 format!(
2501 "could not verify tracked files in {} after editing: {}. The worktree was \
2502 kept for recovery.",
2503 cwd.display(),
2504 e.last_line()
2505 ),
2506 )
2507 })?;
2508 let mut changed = Vec::new();
2509 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
2510 uncertain_worktree_change(
2511 cwd,
2512 format!(
2513 "could not record untracked files before verifying transformed content in {}: \
2514 {}. The worktree was kept for recovery.",
2515 cwd.display(),
2516 e.last_line()
2517 ),
2518 )
2519 })?;
2520 let mut filter_was_run = false;
2521 let mut filter_problem = None;
2522 let mut repositories: BTreeSet<PathBuf> =
2523 baseline.git_state.repositories.keys().cloned().collect();
2524 repositories.extend(after.repositories.keys().cloned());
2525 'repositories: for repository_path in repositories {
2526 let before_repository = baseline.git_state.repositories.get(&repository_path);
2527 let after_repository = after.repositories.get(&repository_path);
2528 if before_repository.is_none() || after_repository.is_none() {
2529 changed.push(repository_path.clone());
2530 continue;
2531 }
2532 if before_repository.map(|repository| &repository.gitlinks)
2533 != after_repository.map(|repository| &repository.gitlinks)
2534 {
2535 changed.push(repository_path.join("<gitlinks>"));
2536 }
2537 let mut paths = BTreeSet::new();
2538 if let Some(repository) = before_repository {
2539 paths.extend(repository.tracked.keys().cloned());
2540 }
2541 if let Some(repository) = after_repository {
2542 paths.extend(repository.tracked.keys().cloned());
2543 }
2544 for path in paths {
2545 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
2546 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
2547 let worktree_changed =
2548 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
2549 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
2550 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
2551 if !worktree_changed {
2552 continue;
2553 }
2554 if !index_changed {
2555 changed.push(repository_path.join(&path));
2556 continue;
2557 }
2558 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
2559 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
2560 let Some(current_entry) = current else {
2561 continue;
2562 };
2563 let Some(current_worktree) = current_worktree else {
2564 continue;
2565 };
2566 let content_changed =
2567 before_worktree.map(|file| file.content) != Some(current_worktree.content);
2568 let mode_changed = before_worktree.map(|file| file.mode.as_str())
2569 != Some(current_worktree.mode.as_str());
2570 let repository = cwd.join(&repository_path);
2571 let represented_content = if content_changed {
2572 filter_was_run = true;
2573 let result =
2574 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
2575 self.refuse_changed_attributes(cwd, baseline)?;
2576 match result {
2577 Ok(expected) => expected == current_worktree.content,
2578 Err(error) => {
2579 filter_problem = Some(format!(
2580 "could not verify transformed content for {:?}: {}",
2581 repository_path.join(&path),
2582 error.last_line()
2583 ));
2584 false
2585 }
2586 }
2587 } else {
2588 true
2589 };
2590 let represented_mode =
2591 !mode_changed || current_worktree.mode == current_entry.index_mode;
2592 if !represented_content || !represented_mode {
2593 changed.push(repository_path.join(&path));
2594 }
2595 if filter_problem.is_some() {
2596 break 'repositories;
2597 }
2598 }
2599 }
2600 if filter_was_run {
2601 self.refuse_changed_attributes(cwd, baseline)?;
2602 let verified = safe_git_state(cwd).map_err(|e| {
2603 uncertain_worktree_change(
2604 cwd,
2605 format!(
2606 "could not recheck tracked files after verifying transformed content in \
2607 {}: {}. The worktree was kept for recovery.",
2608 cwd.display(),
2609 e.last_line()
2610 ),
2611 )
2612 })?;
2613 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
2614 uncertain_worktree_change(
2615 cwd,
2616 format!(
2617 "could not recheck untracked files after verifying transformed content \
2618 in {}: {}. The worktree was kept for recovery.",
2619 cwd.display(),
2620 e.last_line()
2621 ),
2622 )
2623 })?;
2624 if verified != after
2625 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
2626 {
2627 return Err(uncertain_worktree_change(
2628 cwd,
2629 "a content filter changed the worktree while SPAR verified the managed \
2630 commit. The worktree was kept for recovery.",
2631 ));
2632 }
2633 self.refuse_changed_existing_untracked(cwd, baseline)?;
2634 }
2635 if let Some(problem) = filter_problem {
2636 return Err(uncertain_worktree_change(
2637 cwd,
2638 format!("{problem}. The worktree was kept for recovery."),
2639 ));
2640 }
2641 if changed.is_empty() {
2642 return Ok(());
2643 }
2644 let mut listed = changed
2645 .iter()
2646 .take(5)
2647 .map(|path| format!("{:?}", path.as_os_str()))
2648 .collect::<Vec<_>>()
2649 .join(", ");
2650 if changed.len() > 5 {
2651 listed.push_str(&format!(", and {} more", changed.len() - 5));
2652 }
2653 Err(uncertain_worktree_change(
2654 cwd,
2655 format!(
2656 "the editing call changed tracked working-file bytes, modes, repositories, or \
2657 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
2658 recovery."
2659 ),
2660 ))
2661 }
2662
2663 pub(crate) fn refuse_changed_attributes(
2664 &self,
2665 cwd: &Path,
2666 baseline: &WorktreeBaseline,
2667 ) -> Result<()> {
2668 let after = attribute_state(cwd).map_err(|e| {
2669 uncertain_worktree_change(
2670 cwd,
2671 format!(
2672 "could not verify attribute files in {} after editing: {}. The worktree was \
2673 kept for recovery.",
2674 cwd.display(),
2675 e.last_line()
2676 ),
2677 )
2678 })?;
2679 if after == baseline.attributes {
2680 return Ok(());
2681 }
2682 Err(uncertain_worktree_change(
2683 cwd,
2684 format!(
2685 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
2686 refused to run a Git operation that could select a new external filter.",
2687 cwd.display()
2688 ),
2689 ))
2690 }
2691
2692 pub(crate) fn commit_pending_changes(
2697 &self,
2698 cwd: &Path,
2699 baseline: &WorktreeBaseline,
2700 preferred_subject: &str,
2701 fallback_subject: &str,
2702 ) -> Result<bool> {
2703 let mut artifacts = GeneratedArtifacts::default();
2704 self.refuse_changed_attributes(cwd, baseline)?;
2705 artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2706 refuse_unsafe_index_flags(cwd)?;
2707 if !self.has_uncommitted_changes(cwd)? {
2708 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2709 artifacts.report(cwd);
2710 return Ok(false);
2711 }
2712 self.stage_managed_changes(cwd, baseline).map_err(|e| {
2713 e.with_message(format!(
2714 "could not stage changes in {}: {}",
2715 cwd.display(),
2716 e.last_line()
2717 ))
2718 })?;
2719 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2722 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
2723 if !changed_gitlinks.is_empty() {
2724 let listed = changed_gitlinks
2725 .iter()
2726 .take(5)
2727 .map(|path| format!("{:?}", path.as_os_str()))
2728 .collect::<Vec<_>>()
2729 .join(", ");
2730 bail!(
2731 "the editing call added or changed a gitlink at {listed}. It was staged but not \
2732 committed because the referenced repository objects might exist only inside \
2733 this worktree. The worktree was kept for recovery."
2734 );
2735 }
2736 let mut subject = self.clean_title(preferred_subject)?;
2737 if subject.trim().is_empty() {
2738 subject = self.clean_title(fallback_subject)?;
2739 }
2740 self.commit_staged_changes(cwd, &subject).map_err(|e| {
2741 e.with_message(format!(
2742 "could not commit changes in {}: {}. The staged files were kept.",
2743 cwd.display(),
2744 e.last_line()
2745 ))
2746 })?;
2747 if has_tracked_or_staged_work(cwd)? {
2748 bail!(
2749 "the commit in {} left additional uncommitted files. They were kept for \
2750 recovery.",
2751 cwd.display()
2752 );
2753 }
2754 artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2755 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2756 artifacts.report(cwd);
2757 Ok(true)
2758 }
2759
2760 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
2761 let after = ignored_untracked_state(cwd)?;
2762 self.git_at_without_automation(cwd, &["add", "-u"])?;
2763 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
2764 if paths.is_empty() {
2765 return Ok(());
2766 }
2767 let mut input = Vec::new();
2768 for path in paths {
2769 input.extend(os_str_bytes(path.as_os_str())?);
2770 input.push(0);
2771 }
2772 let argv = git_without_automation_argv(&[
2773 "--literal-pathspecs",
2774 "add",
2775 "--pathspec-from-file=-",
2776 "--pathspec-file-nul",
2777 ]);
2778 proc::run_with_input_bytes(
2779 &argv,
2780 &self.git_opts(Some(cwd), true).stop_descendants(true),
2781 &input,
2782 )?;
2783 Ok(())
2784 }
2785
2786 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
2789 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
2790 .map(|_| ())
2791 }
2792
2793 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
2800 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2801 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
2802 .trim()
2803 .parse()
2804 .unwrap_or(0)
2805 }
2806
2807 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2811 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2812 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2813 .lines()
2814 .map(str::to_string)
2815 .collect()
2816 }
2817
2818 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
2833 let ancestor = self
2834 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
2835 .is_ok();
2836 if !ancestor {
2837 return None;
2838 }
2839 let range = format!("{earlier}..{later}");
2840 Some(
2841 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2842 .lines()
2843 .map(str::to_string)
2844 .collect(),
2845 )
2846 }
2847
2848 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2851 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2852 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
2853 .lines()
2854 .map(str::trim)
2855 .filter(|line| !line.is_empty())
2856 .map(str::to_string)
2857 .collect()
2858 }
2859
2860 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
2876 let range = format!("{}...HEAD", self.base_ref(cwd, base));
2877 self.git_try_at(
2878 Some(cwd),
2879 &["diff", "--name-only", "--no-renames", "-z", &range],
2880 )
2881 .split('\0')
2882 .filter(|path| !path.is_empty())
2883 .map(str::to_string)
2884 .collect()
2885 }
2886
2887 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
2890 self.git_try_at(
2891 Some(cwd),
2892 &[
2893 "diff",
2894 "--name-only",
2895 "--no-renames",
2896 "-z",
2897 &format!("{from}..{to}"),
2898 ],
2899 )
2900 .split('\0')
2901 .filter(|path| !path.is_empty())
2902 .map(str::to_string)
2903 .collect()
2904 }
2905
2906 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
2909 let base_ref = self.base_ref(cwd, base);
2910 let out = self
2911 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
2912 .map_err(|e| {
2913 spar_err!(
2914 "could not find where {refname} and {base_ref} diverged. {}",
2915 e.last_line()
2916 )
2917 })?;
2918 let sha = out.trim().to_string();
2919 if sha.is_empty() {
2920 bail!("{refname} and {base_ref} share no history");
2921 }
2922 Ok(sha)
2923 }
2924
2925 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
2926 let range = format!("{}...HEAD", self.base_ref(cwd, base));
2927 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
2928 full.trim().to_string()
2929 }
2930
2931 pub fn rewrite_commits_if_needed(
2944 &self,
2945 cwd: &Path,
2946 base: &str,
2947 floor: Option<&str>,
2948 ) -> Result<()> {
2949 let base_ref = self.base_ref(cwd, base);
2950 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
2951 self.report_commits_left_alone(cwd, &base_ref, floor);
2952
2953 let range = format!("{floor}..HEAD");
2954 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
2955
2956 let offenders = raw
2957 .split('\x1e')
2958 .filter_map(|entry| entry.split_once('\0'))
2959 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
2960 .count();
2961 if offenders == 0 {
2962 return Ok(());
2963 }
2964 logdim!("{offenders} commit message(s) violated style rules, rewriting");
2965
2966 let exe = self_binary()?;
2967 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
2968
2969 let argv: Vec<String> = [
2970 "git",
2971 "filter-branch",
2972 "-f",
2973 "--msg-filter",
2974 &filter,
2975 &range,
2976 ]
2977 .iter()
2978 .map(|s| s.to_string())
2979 .collect();
2980 let opts = ExecOpts::new()
2981 .cwd(cwd)
2982 .check(false)
2983 .timeout_secs(600)
2984 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
2985 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
2986 .env(
2987 "SPAR_BAN_AI_ATTRIBUTION",
2988 bool_env(self.style.ban_ai_attribution),
2989 );
2990 let _ = proc::run(&argv, &opts);
2991
2992 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
2993 if !style::violations(&after, &self.style).is_empty() {
2994 bail!(
2995 "commit messages still violate style rules after a rewrite in {}.",
2996 cwd.display()
2997 );
2998 }
2999 Ok(())
3000 }
3001
3002 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3007 if floor == base_ref {
3008 return;
3009 }
3010 let raw = self.git_try_at(
3011 Some(cwd),
3012 &[
3013 "log",
3014 &format!("{base_ref}..{floor}"),
3015 "--format=%H%x00%B%x1e",
3016 ],
3017 );
3018 let left: Vec<&str> = raw
3019 .split('\x1e')
3020 .filter_map(|entry| entry.split_once('\0'))
3021 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3022 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3023 .collect();
3024 if left.is_empty() {
3025 return;
3026 }
3027 logdim!(
3028 "{} commit message(s) already on the branch break the style rules and were left \
3029 alone: {}. spar does not rewrite commits it did not write.",
3030 left.len(),
3031 left.join(", ")
3032 );
3033 }
3034
3035 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3050 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3051 if let Some(head) = expected {
3052 self.refuse_push_that_drops(cwd, head)?;
3053 }
3054 let refspec = format!("HEAD:{branch}");
3055 let lease = match expected {
3056 Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
3057 None => "--force-with-lease".to_string(),
3058 };
3059 let pushed = self
3060 .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
3061 .map(|_| ())
3062 .map_err(|e| {
3063 spar_err!(
3064 "could not push to origin/{branch}. {}\nCheck push access and whether the \
3065 branch moved under you.",
3066 e.last_line()
3067 )
3068 });
3069 self.record_write(pushed)
3070 }
3071
3072 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
3080 let head = self.head_oid_checked(cwd)?;
3081 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
3082 return Ok(());
3083 }
3084 bail!(
3085 "refusing to push {head} because it does not contain the reviewed head {expected}. \
3086 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
3087 the intended commits on top of it before resuming.",
3088 cwd.display()
3089 )
3090 }
3091
3092 pub fn push_split_branch(
3101 &self,
3102 cwd: &Path,
3103 branch: &str,
3104 ) -> std::result::Result<(), SplitPushError> {
3105 let remote_ref = format!("refs/heads/{branch}");
3106 let lease = format!("--force-with-lease={remote_ref}:");
3107 let refspec = format!("HEAD:{remote_ref}");
3108 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
3109 Ok(_) => Ok(()),
3110 Err(push_error) => {
3111 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
3112 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
3113 reconcile_failed_split_push(branch, push_error, local, remote)
3114 }
3115 };
3116 self.record_write(result)
3117 }
3118
3119 pub fn gh(&self, args: &[&str]) -> Result<String> {
3122 self.gh_at(None, args)
3123 }
3124
3125 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
3126 let mut argv = vec!["gh".to_string()];
3127 argv.extend(args.iter().map(|s| s.to_string()));
3128 proc::run(
3129 &argv,
3130 &ExecOpts::new()
3131 .cwd(cwd.unwrap_or(&self.root))
3132 .timeout_secs(300),
3133 )
3134 }
3135
3136 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3142 let mut argv = vec!["gh".to_string()];
3143 argv.extend(args.iter().map(|s| s.to_string()));
3144 proc::run(
3145 &argv,
3146 &ExecOpts::new()
3147 .cwd(&self.root)
3148 .timeout_secs(300)
3149 .stdin(stdin),
3150 )
3151 }
3152
3153 pub fn gh_try(&self, args: &[&str]) -> String {
3154 let mut argv = vec!["gh".to_string()];
3155 argv.extend(args.iter().map(|s| s.to_string()));
3156 proc::run(
3157 &argv,
3158 &ExecOpts::new()
3159 .cwd(&self.root)
3160 .check(false)
3161 .timeout_secs(300),
3162 )
3163 .unwrap_or_default()
3164 }
3165
3166 pub fn viewer_login(&self) -> Result<&str> {
3177 if let Some(login) = self.viewer.get() {
3178 return Ok(login);
3179 }
3180 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3181 let login = if !rest.trim().is_empty() {
3182 rest.trim().to_string()
3183 } else {
3184 self.gh(&[
3187 "api",
3188 "graphql",
3189 "-f",
3190 "query={ viewer { login } }",
3191 "--jq",
3192 ".data.viewer.login",
3193 ])
3194 .map_err(|e| {
3195 spar_err!(
3196 "could not find out who `gh` is authenticated as, so spar cannot tell its \
3197 own comments from anybody else's. {}\nRun `gh auth status`.",
3198 e.last_line()
3199 )
3200 })?
3201 .trim()
3202 .to_string()
3203 };
3204 if login.is_empty() {
3205 bail!("`gh` reported an empty login. Run `gh auth status`.");
3206 }
3207 Ok(self.viewer.get_or_init(|| login))
3208 }
3209
3210 pub fn read_issue(&self, number: i64) -> Result<Issue> {
3216 let text = self
3217 .gh(&[
3218 "issue",
3219 "view",
3220 &number.to_string(),
3221 "--json",
3222 "number,title,body,labels,state,stateReason,url",
3223 ])
3224 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3225 serde_json::from_str(&text)
3226 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3227 }
3228
3229 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3230 let mut issues = Vec::new();
3231 for number in numbers {
3232 let text = self
3233 .gh(&[
3234 "issue",
3235 "view",
3236 &number.to_string(),
3237 "--json",
3238 "number,title,body,labels,state,stateReason,url",
3239 ])
3240 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3241 let issue: Issue = serde_json::from_str(&text)
3242 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3243 if issue.is_closed() {
3244 crate::log!("issue #{number} is closed, skipping");
3245 continue;
3246 }
3247 issues.push(issue);
3248 }
3249 if issues.is_empty() {
3250 bail!("no open issues to work on");
3251 }
3252 Ok(issues)
3253 }
3254
3255 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3261 #[derive(Deserialize)]
3262 struct Row {
3263 number: i64,
3264 }
3265 let text = self.gh(&[
3266 kind,
3267 "list",
3268 "--state",
3269 "open",
3270 "--limit",
3271 &FETCH_CEILING.to_string(),
3272 "--json",
3273 "number",
3274 ])?;
3275 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3276 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3277 numbers.sort_unstable();
3278
3279 let noun = if kind == "issue" { "issues" } else { "PRs" };
3280 let found = numbers.len();
3281 if min_number > 0 {
3282 numbers.retain(|n| *n >= min_number);
3283 let skipped = found - numbers.len();
3284 if skipped > 0 {
3285 crate::log!("{skipped} open {noun} below #{min_number} skipped");
3286 }
3287 }
3288 if found >= FETCH_CEILING {
3289 crate::log!(
3290 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3291 considered."
3292 );
3293 }
3294 if numbers.len() > limit {
3295 crate::log!(
3296 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3297 explicitly.",
3298 numbers.len()
3299 );
3300 numbers.truncate(limit);
3301 }
3302 Ok(numbers)
3303 }
3304
3305 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3307 self.open_numbers("issue", limit, min_number)
3308 }
3309
3310 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3311 self.open_numbers("pr", limit, min_number)
3312 }
3313
3314 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3315 self.branch_prs(branch, "open").into_iter().next()
3316 }
3317
3318 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3321 let text = self.gh(&[
3322 "pr",
3323 "list",
3324 "--head",
3325 branch,
3326 "--base",
3327 base,
3328 "--state",
3329 "open",
3330 "--json",
3331 "number,url,title,baseRefName",
3332 ])?;
3333 pr_for_base(&text, branch, base)
3334 }
3335
3336 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3340 self.branch_prs(branch, "all")
3341 }
3342
3343 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3344 let text = self.gh_try(&[
3345 "pr",
3346 "list",
3347 "--head",
3348 branch,
3349 "--state",
3350 state,
3351 "--json",
3352 "number,url,title",
3353 ]);
3354 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3355 }
3356
3357 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3363 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3364 let text = self
3365 .gh(&[
3366 "api",
3367 &path,
3368 "--jq",
3369 "if .pull_request then \"pr\" else \"issue\" end",
3370 ])
3371 .map_err(|e| {
3372 spar_err!(
3373 "no issue or pull request #{number} in this repository. {}",
3374 e.last_line()
3375 )
3376 })?;
3377 match text.trim() {
3378 "pr" => Ok(ItemKind::Pr),
3379 "issue" => Ok(ItemKind::Issue),
3380 other => Err(spar_err!(
3381 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3382 )),
3383 }
3384 }
3385
3386 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
3397 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
3398 if let Some(pr) = mine {
3399 return Ok(Some(pr));
3400 }
3401 let listed = match self.gh(&[
3402 "pr",
3403 "list",
3404 "--state",
3405 "open",
3406 "--limit",
3407 &FETCH_CEILING.to_string(),
3408 "--json",
3409 "number,url,title,baseRefName,closingIssuesReferences",
3410 ]) {
3411 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
3412 spar_err!("could not read the pull request list while looking for #{issue}")
3413 })?,
3414 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
3415 };
3416 if listed.len() >= FETCH_CEILING {
3417 logwarn!(
3418 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
3419 );
3420 }
3421 let default = self.default_branch(base);
3422 if !base.eq_ignore_ascii_case(&default) {
3423 logwarn!(
3427 "#{issue}: the base branch is `{base}` and the default is `{default}`. GitHub links closing issues only on the default branch, so an existing pull request for this issue may not be found. Name the pull request instead of the issue if there is one."
3428 );
3429 }
3430 Ok(pick_linked_pr(&listed, issue))
3431 }
3432
3433 fn pr_for_branch_failing_closed(
3435 &self,
3436 branch: &str,
3437 base: &str,
3438 issue: i64,
3439 ) -> Result<Option<PrRef>> {
3440 match self.try_pr_for_branch(branch, base) {
3441 Ok(found) => Ok(found),
3442 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
3443 }
3444 }
3445
3446 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
3455 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
3456 return Err(spar_err!(
3457 "could not check whether a pull request already exists for #{issue}: {}. \
3458 Refusing to implement over the top of work that may already be open.",
3459 e.last_line()
3460 ));
3461 }
3462 logwarn!(
3463 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
3464 whether a pull request already exists for it.",
3465 e.last_line()
3466 );
3467 Ok(())
3468 }
3469
3470 pub fn pr_view(&self, number: i64) -> Result<PrView> {
3471 let text = self.gh(&[
3472 "pr",
3473 "view",
3474 &number.to_string(),
3475 "--json",
3476 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3477 ])?;
3478 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3479 }
3480
3481 fn try_pr_state(&self, number: i64) -> Result<String> {
3482 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3483 serde_json::from_str::<Value>(text.trim())
3484 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3485 .get("state")
3486 .and_then(Value::as_str)
3487 .map(str::to_string)
3488 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3489 }
3490
3491 pub fn pr_state(&self, number: i64) -> String {
3492 self.try_pr_state(number).unwrap_or_default()
3493 }
3494
3495 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3497 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3498 let oid = serde_json::from_str::<Value>(&text)
3499 .ok()
3500 .and_then(|value| {
3501 value
3502 .get("headRefOid")
3503 .and_then(Value::as_str)
3504 .map(str::trim)
3505 .filter(|oid| !oid.is_empty())
3506 .map(str::to_string)
3507 })
3508 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3509 Ok(oid)
3510 }
3511
3512 pub fn create_pr(
3513 &self,
3514 cwd: &Path,
3515 branch: &str,
3516 base: &str,
3517 title: &str,
3518 body: &str,
3519 ) -> Result<PrRef> {
3520 let title = self.record_failed_write(self.clean_title(title))?;
3521 let body = self.record_failed_write(self.clean(body))?;
3522 let mut argv = vec![
3523 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3524 ];
3525 if self.drafts != Drafts::Never {
3526 argv.push("--draft");
3527 }
3528 let created = self.gh_at(Some(cwd), &argv);
3529 let found = self.try_pr_for_branch(branch, base);
3530 self.record_write(reconcile_pr_creation(branch, created, found))
3531 }
3532
3533 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3534 let body = signed(&self.record_failed_write(self.clean(body))?);
3535 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3536 if has_exact_comment(&comments, &body) {
3537 return Ok(());
3538 }
3539 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3540 let result = match posted {
3541 Ok(_) => Ok(()),
3542 Err(post_error) => {
3543 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3544 }
3545 };
3546 self.record_write(result)
3547 }
3548
3549 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3550 let body = signed(&self.record_failed_write(self.clean(body))?);
3551 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3552 if has_exact_comment(&comments, &body) {
3553 return Ok(());
3554 }
3555 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3556 let result = match posted {
3557 Ok(_) => Ok(()),
3558 Err(post_error) => {
3559 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3560 }
3561 };
3562 self.record_write(result)
3563 }
3564
3565 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3570 self.comment_issue(number, body)?;
3571 let n = number.to_string();
3572 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3573 Ok(_) => Ok(()),
3574 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3576 spar_err!(
3577 "commented on #{number} but could not close it: {}",
3578 e.last_line()
3579 )
3580 }),
3581 };
3582 self.record_write(closed)
3583 }
3584
3585 pub fn edit_issue_body(
3600 &self,
3601 number: i64,
3602 expected: &str,
3603 body: &str,
3604 inserted: &str,
3605 ) -> Result<()> {
3606 let cleaned = self.record_failed_write(self.clean(inserted))?;
3607 if cleaned.trim() != inserted.trim() {
3608 return self.record_failed_write(Err(spar_err!(
3609 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
3610 )));
3611 }
3612 let current = self.record_failed_write(self.issue_body(number))?;
3613 if current != expected {
3614 return self.record_failed_write(Err(spar_err!(
3615 "the body of #{number} changed since it was read, so it was left alone rather \
3616 than written over."
3617 )));
3618 }
3619 let edited = self.gh_stdin(
3620 &["issue", "edit", &number.to_string(), "--body-file", "-"],
3621 body,
3622 );
3623 let result = match edited {
3624 Ok(_) => Ok(()),
3625 Err(edit_error) => {
3626 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
3627 }
3628 };
3629 self.record_write(result)
3630 }
3631
3632 pub fn issue_body(&self, number: i64) -> Result<String> {
3634 #[derive(Deserialize)]
3635 struct Row {
3636 #[serde(default)]
3637 body: Option<String>,
3638 }
3639 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
3640 let row: Row = serde_json::from_str(text.trim())
3641 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3642 Ok(row.body.unwrap_or_default())
3643 }
3644
3645 pub fn open_issue_rows(&self) -> Vec<Issue> {
3650 let text = self.gh_try(&[
3651 "issue",
3652 "list",
3653 "--state",
3654 "open",
3655 "--limit",
3656 &FETCH_CEILING.to_string(),
3657 "--json",
3658 "number,title,body,labels,state,stateReason,url",
3659 ]);
3660 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
3661 }
3662
3663 pub fn open_pr_rows(&self) -> Vec<PrRow> {
3665 let text = self.gh_try(&[
3666 "pr",
3667 "list",
3668 "--state",
3669 "open",
3670 "--limit",
3671 &FETCH_CEILING.to_string(),
3672 "--json",
3673 "number,title,changedFiles,additions,deletions",
3674 ]);
3675 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
3676 }
3677
3678 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
3679 self.create_issue_apart_from(title, body, None)
3680 }
3681
3682 pub fn create_issue_apart_from(
3683 &self,
3684 title: &str,
3685 body: &str,
3686 apart_from: Option<i64>,
3687 ) -> Result<String> {
3688 let title = self.record_failed_write(self.clean_title(title))?;
3689 let body = self.record_failed_write(self.clean_issue_body(body))?;
3690 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
3691 let result = match created {
3692 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
3693 created => {
3694 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
3695 reconcile_issue_creation(&title, created, found)
3696 }
3697 };
3698 self.record_write(result)
3699 }
3700}
3701
3702#[derive(Debug, Clone)]
3704pub struct ExistingIssue {
3705 pub number: i64,
3706 pub url: String,
3707 pub title: String,
3708 pub body: String,
3709 pub open: bool,
3710}
3711
3712impl Repo {
3713 pub(crate) fn try_exact_issue_apart_from(
3714 &self,
3715 title: &str,
3716 body: &str,
3717 apart_from: Option<i64>,
3718 ) -> Result<Option<ExistingIssue>> {
3719 #[derive(Deserialize)]
3720 #[serde(rename_all = "camelCase")]
3721 struct Row {
3722 number: i64,
3723 #[serde(default)]
3724 title: String,
3725 #[serde(default)]
3726 url: String,
3727 #[serde(default)]
3728 body: Option<String>,
3729 #[serde(default)]
3730 state: String,
3731 }
3732
3733 let text = self.gh(&[
3734 "issue",
3735 "list",
3736 "--state",
3737 "all",
3738 "--limit",
3739 "100",
3740 "--json",
3741 "number,title,url,body,state,stateReason",
3742 ])?;
3743 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3744 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
3745 Ok(rows
3746 .into_iter()
3747 .filter(|row| Some(row.number) != apart_from)
3748 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
3749 .map(|row| ExistingIssue {
3750 number: row.number,
3751 url: row.url,
3752 title: row.title,
3753 body: row.body.unwrap_or_default(),
3754 open: row.state.eq_ignore_ascii_case("open"),
3755 }))
3756 }
3757
3758 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
3766 self.find_similar_issue_apart_from(title, body, None)
3767 }
3768
3769 pub fn find_similar_issue_apart_from(
3777 &self,
3778 title: &str,
3779 body: &str,
3780 apart_from: Option<i64>,
3781 ) -> Option<ExistingIssue> {
3782 self.try_similar_issue(title, body, apart_from, true)
3783 .ok()
3784 .flatten()
3785 }
3786
3787 pub fn try_find_similar_issue_apart_from(
3789 &self,
3790 title: &str,
3791 body: &str,
3792 apart_from: Option<i64>,
3793 ) -> Result<Option<ExistingIssue>> {
3794 self.try_similar_issue(title, body, apart_from, false)
3795 }
3796
3797 fn try_similar_issue(
3799 &self,
3800 title: &str,
3801 body: &str,
3802 apart_from: Option<i64>,
3803 include_closed: bool,
3804 ) -> Result<Option<ExistingIssue>> {
3805 #[derive(Deserialize)]
3806 #[serde(rename_all = "camelCase")]
3807 struct Row {
3808 number: i64,
3809 #[serde(default)]
3810 title: String,
3811 #[serde(default)]
3812 url: String,
3813 #[serde(default)]
3814 body: String,
3815 #[serde(default)]
3816 state: String,
3817 }
3818 if title.trim().is_empty() {
3819 return Ok(None);
3820 }
3821 let query: String = title
3824 .chars()
3825 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3826 .take(120)
3827 .collect();
3828 let state = if include_closed { "all" } else { "open" };
3837 let text = self.gh(&[
3838 "issue",
3839 "list",
3840 "--state",
3841 state,
3842 "--limit",
3843 "100",
3844 "--search",
3845 query.trim(),
3846 "--json",
3847 "number,title,url,body,state,stateReason",
3848 ])?;
3849 let rows: Vec<Row> = serde_json::from_str(text.trim())
3850 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
3851
3852 Ok(rows
3853 .into_iter()
3854 .filter(|row| Some(row.number) != apart_from)
3855 .find(|row| {
3856 row.title.trim().eq_ignore_ascii_case(title.trim())
3857 || textsim::covers(title, body, &row.title, &row.body)
3858 })
3859 .map(|row| ExistingIssue {
3860 number: row.number,
3861 url: row.url,
3862 title: row.title,
3863 open: row.state.eq_ignore_ascii_case("open"),
3864 body: row.body,
3865 }))
3866 }
3867
3868 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
3870 #[derive(Deserialize)]
3871 struct Row {
3872 title: String,
3873 url: String,
3874 }
3875 let needle = title.trim().to_lowercase();
3876 if needle.is_empty() {
3877 return None;
3878 }
3879 let query: String = title
3881 .chars()
3882 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3883 .take(120)
3884 .collect();
3885 let text = self.gh_try(&[
3886 "issue",
3887 "list",
3888 "--state",
3889 "all",
3890 "--limit",
3891 "100",
3892 "--search",
3893 query.trim(),
3894 "--json",
3895 "number,title,url",
3896 ]);
3897 serde_json::from_str::<Vec<Row>>(text.trim())
3898 .ok()?
3899 .into_iter()
3900 .find(|row| row.title.trim().to_lowercase() == needle)
3901 .map(|row| row.url)
3902 }
3903
3904 pub fn mark_ready(&self, number: i64) -> bool {
3912 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
3913 Ok(_) => true,
3914 Err(e) => {
3915 logdim!(
3916 "PR #{number} is approved but could not be taken out of draft: {}",
3917 e.last_line()
3918 );
3919 false
3920 }
3921 }
3922 }
3923
3924 pub fn merge_pr(&self, number: i64) -> Result<()> {
3928 let n = number.to_string();
3929 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
3930 Ok(_) => Ok(()),
3931 Err(e) => {
3932 if self.pr_state(number) == "MERGED" {
3933 logdim!(
3934 "PR #{number} merged; branch cleanup did not finish: {}",
3935 e.last_line()
3936 );
3937 Ok(())
3938 } else {
3939 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3940 }
3941 }
3942 };
3943 self.record_write(merged)
3944 }
3945
3946 pub fn merge_pr_at_head(
3948 &self,
3949 number: i64,
3950 expected_head: &str,
3951 delete_branch: bool,
3952 ) -> Result<()> {
3953 let n = number.to_string();
3954 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
3955 Ok(_) => Ok(()),
3956 Err(e) => {
3957 if self.pr_state(number) == "MERGED" {
3958 logdim!(
3959 "PR #{number} merged; branch cleanup did not finish: {}",
3960 e.last_line()
3961 );
3962 Ok(())
3963 } else {
3964 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3965 }
3966 }
3967 };
3968 self.record_write(merged)
3969 }
3970
3971 pub fn followups_path(&self) -> PathBuf {
3976 self.root.join(STATE_DIR).join("followups.md")
3977 }
3978
3979 pub fn worked_followups_path(&self) -> PathBuf {
3987 self.root.join(STATE_DIR).join("followups.done.md")
3988 }
3989
3990 pub fn spend_path(&self) -> PathBuf {
3993 self.root.join(STATE_DIR).join("state").join("spend.json")
3994 }
3995
3996 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4002 if calls.is_empty() {
4003 return Ok(());
4004 }
4005 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4006 .ok()
4007 .and_then(|text| serde_json::from_str(&text).ok())
4008 .unwrap_or_default();
4009 all.extend(calls.iter().cloned());
4010 if let Some(parent) = self.spend_path().parent() {
4011 let _ = std::fs::create_dir_all(parent);
4012 }
4013 write_json_atomic(&self.spend_path(), &all)
4014 }
4015
4016 pub fn implementors_path(&self) -> PathBuf {
4022 self.root
4023 .join(STATE_DIR)
4024 .join("state")
4025 .join("implementors.json")
4026 }
4027
4028 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4029 std::fs::read_to_string(self.implementors_path())
4030 .ok()
4031 .and_then(|text| serde_json::from_str(&text).ok())
4032 .unwrap_or_default()
4033 }
4034
4035 pub fn remember_implementor(&self, issue: i64, agent: &str) {
4038 let mut known = self.read_implementors();
4039 known.insert(issue, agent.to_string());
4040 if let Some(parent) = self.implementors_path().parent() {
4041 let _ = std::fs::create_dir_all(parent);
4042 }
4043 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4044 logdim!("could not record who implemented #{issue}: {e}");
4045 }
4046 }
4047
4048 pub fn contested_path(&self) -> PathBuf {
4053 self.root
4054 .join(STATE_DIR)
4055 .join("state")
4056 .join("contested.json")
4057 }
4058
4059 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4061 std::fs::read_to_string(self.contested_path())
4062 .ok()
4063 .and_then(|text| serde_json::from_str(&text).ok())
4064 .unwrap_or_default()
4065 }
4066
4067 pub fn remember_contested(&self, items: &[ContestedItem]) {
4072 if items.is_empty() {
4073 return;
4074 }
4075 let mut known = self.read_contested();
4076 for item in items {
4077 known.insert(
4078 item.issue,
4079 Contested {
4080 title: item.title.clone(),
4081 positions: item.positions.clone(),
4082 reasons: item.reasons.clone(),
4083 },
4084 );
4085 }
4086 if let Some(parent) = self.contested_path().parent() {
4087 let _ = std::fs::create_dir_all(parent);
4088 }
4089 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4090 logdim!("could not record the contested issues: {e}");
4091 }
4092 }
4093
4094 pub fn forget_contested(&self, numbers: &[i64]) {
4096 let mut known = self.read_contested();
4097 let before = known.len();
4098 known.retain(|number, _| !numbers.contains(number));
4099 if known.len() == before {
4100 return;
4101 }
4102 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4103 logdim!("could not update the contested issues: {e}");
4104 }
4105 }
4106
4107 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4108 self.root
4109 .join(STATE_DIR)
4110 .join("state")
4111 .join(format!("checkin-{number}.json"))
4112 }
4113
4114 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4129 let path = self.followups_path();
4130 let heading = format!("## {}", title.trim());
4131 for seen in [&path, &self.worked_followups_path()] {
4132 if let Ok(existing) = std::fs::read_to_string(seen) {
4133 if has_heading(&existing, title) {
4134 logdim!("follow-up already noted: {title}");
4135 return Followup::Covered(format!("note: {}", title.trim()));
4136 }
4137 }
4138 }
4139 if let Some(parent) = path.parent() {
4140 let _ = std::fs::create_dir_all(parent);
4141 }
4142 use std::io::Write;
4143 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4150 match std::fs::OpenOptions::new()
4151 .create(true)
4152 .append(true)
4153 .open(&path)
4154 {
4155 Ok(mut file) => match file.write_all(entry.as_bytes()) {
4156 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4157 Err(e) => {
4158 logdim!("could not write {}: {e}", path.display());
4159 Followup::Failed
4160 }
4161 },
4162 Err(e) => {
4163 logdim!("could not write {}: {e}", path.display());
4164 Followup::Failed
4165 }
4166 }
4167 }
4168
4169 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4174 let path = self.worked_followups_path();
4175 if let Some(parent) = path.parent() {
4176 let _ = std::fs::create_dir_all(parent);
4177 }
4178 use std::io::Write;
4179 let entry = format!(
4180 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4181 title.trim(),
4182 body.trim()
4183 );
4184 if let Ok(mut file) = std::fs::OpenOptions::new()
4185 .create(true)
4186 .append(true)
4187 .open(&path)
4188 {
4189 let _ = file.write_all(entry.as_bytes());
4190 }
4191 }
4192
4193 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4202 self.root
4203 .join(STATE_DIR)
4204 .join("reviews")
4205 .join(format!("pr-{number}.md"))
4206 }
4207
4208 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4214 let path = self.pending_comment_path(number);
4215 if let Some(parent) = path.parent() {
4216 std::fs::create_dir_all(parent)
4217 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4218 }
4219 std::fs::write(&path, text)
4220 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4221 Ok(path)
4222 }
4223
4224 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4225 std::fs::read_to_string(self.pending_comment_path(number)).ok()
4226 }
4227
4228 pub fn state_path(&self, number: i64) -> PathBuf {
4229 self.root
4230 .join(STATE_DIR)
4231 .join("state")
4232 .join(format!("pr-{number}.json"))
4233 }
4234
4235 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4236 let path = self.state_path(number);
4237 let text = std::fs::read_to_string(&path).ok()?;
4238 match serde_json::from_str(&text) {
4239 Ok(state) => Some(state),
4240 Err(_) => {
4241 logdim!("could not read {}, starting fresh", path.display());
4242 None
4243 }
4244 }
4245 }
4246
4247 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4248 if let Some(local) = self.read_local_state(pr.number) {
4249 return Some(local);
4250 }
4251 if self.state_store.writes_pr() {
4252 return self.read_pr_state(pr.number);
4253 }
4254 None
4255 }
4256
4257 pub(crate) fn read_state_for_head(
4258 &self,
4259 pr: &PrView,
4260 actual_head: &str,
4261 ) -> Option<PersistedState> {
4262 let local = self
4263 .state_store
4264 .writes_local()
4265 .then(|| self.read_local_state(pr.number))
4266 .flatten();
4267 let remote = self
4268 .state_store
4269 .writes_pr()
4270 .then(|| self.read_pr_state(pr.number))
4271 .flatten();
4272 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4273 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4274 self.remember_checkpoint(pr.number, checkpoint);
4275 }
4276 choose_state_for_head(candidates, actual_head)
4277 }
4278
4279 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4280 self.try_read_pr_state(number).ok().flatten()
4281 }
4282
4283 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4284 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4285 if let Some(state) = parse_state_comment(&body) {
4286 return Ok(Some(state));
4287 }
4288 }
4289 Ok(None)
4290 }
4291
4292 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4293 let remote_state = if self.state_store.writes_pr() {
4294 self.try_read_pr_state(number)
4295 } else {
4296 Ok(None)
4297 };
4298 self.write_state_after_remote_read(number, state, remote_state)
4299 }
4300
4301 fn write_state_after_remote_read(
4302 &self,
4303 number: i64,
4304 state: &PersistedState,
4305 remote_state: Result<Option<PersistedState>>,
4306 ) -> Result<()> {
4307 let remote_checkpoint = if self.state_store.writes_pr() {
4308 self.record_failed_write(remote_state)?
4309 .map(|saved| saved.checkpoint)
4310 .unwrap_or_default()
4311 } else {
4312 0
4313 };
4314 let local_checkpoint = self
4315 .state_store
4316 .writes_local()
4317 .then(|| self.read_local_state(number))
4318 .flatten()
4319 .map(|saved| saved.checkpoint)
4320 .unwrap_or_default();
4321 let mut stamped = state.clone();
4322 stamped.checkpoint = state
4323 .checkpoint
4324 .max(local_checkpoint)
4325 .max(remote_checkpoint)
4326 .max(self.remembered_checkpoint(number))
4327 .saturating_add(1);
4328 self.remember_checkpoint(number, stamped.checkpoint);
4329 if self.state_store.writes_local() {
4330 write_json_atomic(&self.state_path(number), &stamped)?;
4331 }
4332 if self.state_store.writes_pr() {
4333 self.write_pr_state(number, &stamped)?;
4334 }
4335 Ok(())
4336 }
4337
4338 fn remembered_checkpoint(&self, number: i64) -> u64 {
4339 self.checkpoints
4340 .lock()
4341 .unwrap_or_else(std::sync::PoisonError::into_inner)
4342 .get(&number)
4343 .copied()
4344 .unwrap_or_default()
4345 }
4346
4347 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4348 let mut checkpoints = self
4349 .checkpoints
4350 .lock()
4351 .unwrap_or_else(std::sync::PoisonError::into_inner);
4352 let saved = checkpoints.entry(number).or_default();
4353 *saved = (*saved).max(checkpoint);
4354 }
4355
4356 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4357 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4361 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4362 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4363 if let Some(id) = comment_id {
4364 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4365 let field = format!("body={body}");
4366 let written = self
4367 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4368 .map(|_| ());
4369 return self.record_write(written);
4370 }
4371 let written = self
4372 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4373 .map(|_| ());
4374 self.record_write(written)
4375 }
4376
4377 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4385 self.try_issue_comments(number).unwrap_or_default()
4386 }
4387
4388 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4389 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4390 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4391 }
4392
4393 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4400 let viewer = self.viewer_login()?.to_string();
4401 let mut mine = Vec::new();
4402 let mut theirs = Vec::new();
4403 for comment in self.try_issue_comments(number)? {
4404 let Some(body) = comment.get("body").and_then(Value::as_str) else {
4405 continue;
4406 };
4407 if !body.contains("spar:state") {
4408 continue;
4409 }
4410 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4411 continue;
4412 };
4413 let author = comment
4414 .get("user")
4415 .and_then(|u| u.get("login"))
4416 .and_then(Value::as_str)
4417 .unwrap_or_default();
4418 if is_state_comment(body, author, &viewer) {
4419 mine.push((id, body.to_string()));
4420 } else {
4421 theirs.push(format!("@{author}"));
4422 }
4423 }
4424 if !theirs.is_empty() {
4425 logdim!(
4426 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4427 own state block is patched or deleted.",
4428 theirs.len(),
4429 theirs.join(", ")
4430 );
4431 }
4432 Ok(mine)
4433 }
4434
4435 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4436 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4437 }
4438
4439 pub fn clear_state(&self, number: i64) {
4441 let path = self.state_path(number);
4442 let _ = std::fs::remove_file(&path);
4443 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4444 }
4445
4446 pub fn prune_state(&self) -> Vec<String> {
4450 let base = self.root.join(STATE_DIR).join("state");
4451 let Ok(entries) = std::fs::read_dir(&base) else {
4452 return Vec::new();
4453 };
4454 let mut names: Vec<String> = entries
4455 .flatten()
4456 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4457 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4458 .collect();
4459 names.sort();
4460
4461 let mut removed = Vec::new();
4462 for name in names {
4463 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4464 continue;
4465 };
4466 if is_finished(&self.pr_state(number)) {
4467 let _ = std::fs::remove_file(base.join(&name));
4468 removed.push(format!("state {name}"));
4469 }
4470 }
4471 removed
4472 }
4473
4474 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4478 #[derive(Deserialize)]
4479 struct Row {
4480 number: i64,
4481 }
4482 let numbers = match numbers {
4483 Some(numbers) => numbers,
4484 None => {
4485 let listed: Result<Vec<i64>> = (|| {
4486 let text = self.gh(&[
4487 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4488 ])?;
4489 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4490 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4491 Ok(rows.into_iter().map(|row| row.number).collect())
4492 })();
4493 match self.record_failed_write(listed) {
4494 Ok(numbers) => numbers,
4495 Err(e) => {
4496 logdim!("could not inspect pull requests for state cleanup: {e}");
4497 return Vec::new();
4498 }
4499 }
4500 }
4501 };
4502
4503 let mut removed = Vec::new();
4504 for number in numbers {
4505 let state = match self.record_failed_write(self.try_pr_state(number)) {
4506 Ok(state) => state,
4507 Err(e) => {
4508 logdim!("could not inspect PR #{number} for state cleanup: {e}");
4509 continue;
4510 }
4511 };
4512 if !is_finished(&state) {
4513 continue;
4514 }
4515 let comments = match self.record_failed_write(self.try_state_comments(number)) {
4516 Ok(comments) => comments,
4517 Err(e) => {
4518 logdim!("could not inspect state comments on PR #{number}: {e}");
4519 continue;
4520 }
4521 };
4522 for (id, _) in comments {
4523 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4524 let deleted = self
4525 .gh(&["api", "-X", "DELETE", &path, "--silent"])
4526 .map(|_| ());
4527 match self.record_write(deleted) {
4528 Ok(()) => removed.push(format!("state comment on PR #{number}")),
4529 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
4530 }
4531 }
4532 }
4533 removed
4534 }
4535
4536 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
4543 let base = self.root.join(WORKTREE_DIR);
4544 let mut removed = Vec::new();
4545 let known = self.known_branches();
4546
4547 if let Ok(entries) = std::fs::read_dir(&base) {
4548 let mut names: Vec<String> = entries
4549 .flatten()
4550 .filter(|e| e.path().is_dir())
4551 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4552 .collect();
4553 names.sort();
4554
4555 for name in names {
4556 if let Some(rest) = name.strip_prefix("review-") {
4559 let number: i64 = rest.parse().unwrap_or(-1);
4560 if !(force_all || is_finished(&self.pr_state(number))) {
4561 continue;
4562 }
4563 let path = base.join(&name);
4564 if force_all {
4565 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4566 if !belongs {
4567 return Ok(false);
4568 }
4569 let local_ref = review_ref(number);
4570 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4571 return Ok(false);
4572 }
4573 let head = self.head_oid_checked(&path)?;
4574 let recorded = self
4575 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4576 .trim()
4577 .to_string();
4578 Ok(head == recorded)
4579 });
4580 match owned {
4581 Ok(true) => {}
4582 Ok(false) => {
4583 logdim!(
4584 "kept {} because no matching SPAR review reference proves \
4585 ownership",
4586 path.display()
4587 );
4588 continue;
4589 }
4590 Err(e) => {
4591 logdim!(
4592 "kept {} because review ownership could not be verified: {}",
4593 path.display(),
4594 e.last_line()
4595 );
4596 continue;
4597 }
4598 }
4599 } else {
4600 if let Err(e) = self.refuse_review_worktree_changes(number) {
4601 logdim!(
4602 "kept {} because its review state could not be verified as \
4603 disposable: {}",
4604 path.display(),
4605 e.last_line()
4606 );
4607 continue;
4608 }
4609 }
4610 if force_all {
4611 if self.remove_worktree_at_force(&path) {
4612 self.git_try(&["update-ref", "-d", &review_ref(number)]);
4613 }
4614 } else {
4615 self.release_review_worktree(number);
4616 }
4617 if !path.exists() {
4618 removed.push(name);
4619 }
4620 continue;
4621 }
4622 let branch = format!("{}{name}", self.branch_prefix);
4623 if !(force_all || self.worktree_is_done(&branch)) {
4624 continue;
4625 }
4626 if !known.contains_key(&branch) {
4627 logdim!("kept {branch} because it has no branch record");
4628 continue;
4629 }
4630 let path = base.join(&name);
4631 if !force_all {
4632 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
4633 Ok(true) => {
4634 logdim!(
4635 "kept {} because it contains uncommitted changes or ignored files",
4636 path.display()
4637 );
4638 continue;
4639 }
4640 Err(e) => {
4641 logdim!(
4642 "kept {} because its Git state could not be checked: {}",
4643 path.display(),
4644 e.last_line()
4645 );
4646 continue;
4647 }
4648 Ok(false) => {}
4649 }
4650 match self.branch_deletion_is_safe(&branch) {
4651 Ok(true) => {}
4652 Ok(false) => {
4653 logdim!(
4654 "kept {branch} because no surviving ref preserves its tip or \
4655 reflog-only commits"
4656 );
4657 continue;
4658 }
4659 Err(e) => {
4660 logdim!(
4661 "kept {branch} because preservation could not be verified: {}",
4662 e.last_line()
4663 );
4664 continue;
4665 }
4666 }
4667 }
4668 let removed_worktree = if force_all {
4669 self.remove_worktree_at_force(&path)
4670 } else {
4671 match self.remove_worktree_at(&path) {
4672 Ok(removed) => removed,
4673 Err(error) => {
4674 logdim!(
4675 "kept {branch} and {} because removal did not reach a confirmed \
4676 quiet point: {}",
4677 path.display(),
4678 error.last_line()
4679 );
4680 false
4681 }
4682 }
4683 };
4684 if !removed_worktree {
4685 continue;
4686 }
4687 if force_all {
4688 self.git_try(&["branch", "-D", &branch]);
4689 self.forget_branch(&branch);
4690 } else {
4691 match self.delete_branch_if_safe(&branch) {
4692 Ok(true) => self.forget_branch(&branch),
4693 Ok(false) => logdim!(
4694 "kept {branch} because its tip or reflog changed before deletion"
4695 ),
4696 Err(error) => logdim!(
4697 "kept {branch} because deletion safety could not be rechecked: {}",
4698 error.last_line()
4699 ),
4700 }
4701 }
4702 removed.push(name);
4703 }
4704 }
4705 removed.extend(self.prune_branches(force_all));
4706 removed
4707 }
4708
4709 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
4716 let known = self.known_branches();
4717 let branches: Vec<String> = known.keys().cloned().collect();
4718 if branches.is_empty() {
4719 return Vec::new();
4720 }
4721
4722 let checked_out: Vec<String> = self
4723 .git_try(&["worktree", "list", "--porcelain"])
4724 .lines()
4725 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
4726 .collect();
4727
4728 let existing: Vec<String> = self
4731 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
4732 .lines()
4733 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
4734 .collect();
4735
4736 let mut removed = Vec::new();
4737 for branch in branches {
4738 if !existing.contains(&branch) {
4739 self.forget_branch(&branch); continue;
4741 }
4742 if checked_out.contains(&branch) {
4743 continue;
4744 }
4745 if !(force_all || self.worktree_is_done(&branch)) {
4746 continue;
4747 }
4748 if !force_all {
4749 let Some(_record) = known.get(&branch) else {
4750 continue;
4751 };
4752 match self.branch_deletion_is_safe(&branch) {
4753 Ok(true) => {}
4754 Ok(false) => {
4755 logdim!(
4756 "kept {branch} because no surviving ref preserves its tip or \
4757 reflog-only commits"
4758 );
4759 continue;
4760 }
4761 Err(e) => {
4762 logdim!(
4763 "kept {branch} because preservation could not be verified: {}",
4764 e.last_line()
4765 );
4766 continue;
4767 }
4768 }
4769 }
4770 let deleted = if force_all {
4771 self.git(&["branch", "-D", &branch]).map(|_| true)
4772 } else {
4773 self.delete_branch_if_safe(&branch)
4774 };
4775 match deleted {
4776 Ok(true) => {
4777 self.forget_branch(&branch);
4778 removed.push(format!("branch {branch}"));
4779 }
4780 Ok(false) => {
4781 logdim!("kept {branch} because its tip or reflog changed before deletion");
4782 }
4783 Err(e) => {
4784 logdim!("could not delete {branch}: {}", e.last_line());
4787 }
4788 }
4789 }
4790 removed
4791 }
4792
4793 fn worktree_is_done(&self, branch: &str) -> bool {
4795 #[derive(Deserialize)]
4796 struct Row {
4797 state: String,
4798 }
4799 let entry = branch
4800 .strip_prefix(self.branch_prefix.as_str())
4801 .unwrap_or(branch);
4802 if let Some(rest) = entry.strip_prefix("pr-") {
4803 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
4804 }
4805 if entry.starts_with("issue-") || entry.starts_with("split-") {
4809 let text = self.gh_try(&[
4810 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
4811 ]);
4812 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4813 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
4814 }
4815 false
4816 }
4817}
4818
4819pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
4831 match probe() {
4832 Ok(value) => Ok(value),
4833 Err(first) => {
4834 logdim!("retrying a worktree probe after: {}", first.last_line());
4835 probe()
4836 }
4837 }
4838}
4839
4840pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
4846 let root = std::fs::canonicalize(cwd)
4847 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
4848 let mut files = BTreeMap::new();
4849 let mut visited = BTreeSet::new();
4850 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
4851 Ok(AttributeState { files })
4852}
4853
4854fn collect_attribute_files(
4855 root: &Path,
4856 repository: &Path,
4857 prefix: &Path,
4858 visited: &mut BTreeSet<PathBuf>,
4859 files: &mut BTreeMap<PathBuf, [u8; 32]>,
4860) -> Result<()> {
4861 let canonical = std::fs::canonicalize(repository)
4862 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
4863 if !visited.insert(canonical) {
4864 bail!("submodule recursion revisited {}", repository.display());
4865 }
4866 let entries = index_entries(repository)?;
4867 let mut paths: BTreeSet<PathBuf> = entries
4868 .iter()
4869 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
4870 .map(|entry| entry.path.clone())
4871 .collect();
4872 let untracked = run_git_bytes(
4878 repository,
4879 &[
4880 "ls-files",
4881 "--others",
4882 "--exclude-standard",
4883 "-z",
4884 "--",
4885 ".gitattributes",
4886 ":(glob)**/.gitattributes",
4887 ],
4888 )?;
4889 if !untracked.is_empty() && !untracked.ends_with(&[0]) {
4890 bail!(
4891 "git returned an unterminated attribute-file listing for {}",
4892 repository.display()
4893 );
4894 }
4895 for raw in untracked
4896 .split(|byte| *byte == 0)
4897 .filter(|record| !record.is_empty())
4898 {
4899 paths.insert(safe_git_path(raw, "attribute")?);
4900 }
4901 for path in paths {
4902 let from_root = prefix.join(&path);
4903 let state = attribute_file_fingerprint(&root.join(&from_root))?;
4904 files.insert(from_root, state);
4905 }
4906 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
4907 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
4908 continue;
4909 };
4910 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
4911 }
4912 Ok(())
4913}
4914
4915pub(crate) fn uncertain_worktree_change(
4918 cwd: &Path,
4919 message: impl Into<String>,
4920) -> crate::error::SparError {
4921 let message = message.into();
4922 let marker = write_recovery_marker(cwd, &message);
4923 let note = match marker {
4924 Ok(path) => format!(" Recovery marker: {}.", path.display()),
4925 Err(e) => format!(
4926 " A recovery marker could not be written: {}.",
4927 e.last_line()
4928 ),
4929 };
4930 crate::error::SparError::uncertain_write(format!("{message}{note}"))
4931}
4932
4933fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
4934 use std::sync::atomic::{AtomicU32, Ordering};
4935 static NEXT: AtomicU32 = AtomicU32::new(0);
4936 for _ in 0..1000 {
4937 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
4938 let path = cwd.join(format!(
4939 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
4940 std::process::id()
4941 ));
4942 let mut options = OpenOptions::new();
4943 options.write(true).create_new(true);
4944 #[cfg(unix)]
4945 {
4946 use std::os::unix::fs::OpenOptionsExt;
4947 options.mode(0o600);
4948 }
4949 match options.open(&path) {
4950 Ok(mut file) => {
4951 file.write_all(detail.as_bytes())
4952 .and_then(|_| file.write_all(b"\n"))
4953 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4954 return Ok(path);
4955 }
4956 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
4957 Err(e) => {
4958 return Err(spar_err!(
4959 "could not create a recovery marker in {}: {e}",
4960 cwd.display()
4961 ))
4962 }
4963 }
4964 }
4965 bail!(
4966 "could not choose a free recovery marker name in {}",
4967 cwd.display()
4968 )
4969}
4970
4971fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
4976 let mut argv = vec![
4977 "git".to_string(),
4978 "-c".to_string(),
4979 "maintenance.auto=false".to_string(),
4980 "-c".to_string(),
4981 "gc.auto=0".to_string(),
4982 ];
4983 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4984 argv
4985}
4986
4987fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
4988 let mut argv = git_without_maintenance_argv(&[]);
4989 argv.extend([
4990 "-c".to_string(),
4991 "core.fsmonitor=".to_string(),
4992 "-c".to_string(),
4993 "commit.gpgsign=false".to_string(),
4994 "-c".to_string(),
4995 "core.hooksPath=/dev/null".to_string(),
4996 ]);
4997 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4998 argv
4999}
5000
5001pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5008 let root = std::fs::canonicalize(cwd)
5009 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5010 let mut files = BTreeMap::new();
5011 let mut ignored = BTreeSet::new();
5012 let mut visited = BTreeSet::new();
5013 collect_untracked_files(
5014 &root,
5015 &root,
5016 Path::new(""),
5017 &mut visited,
5018 &mut files,
5019 &mut ignored,
5020 )?;
5021 Ok(IgnoredState { files, ignored })
5022}
5023
5024fn collect_untracked_files(
5025 root: &Path,
5026 repository: &Path,
5027 prefix: &Path,
5028 visited: &mut BTreeSet<PathBuf>,
5029 files: &mut BTreeMap<PathBuf, UntrackedFile>,
5030 ignored: &mut BTreeSet<PathBuf>,
5031) -> Result<()> {
5032 let canonical = std::fs::canonicalize(repository)
5033 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5034 if !visited.insert(canonical.clone()) {
5035 bail!("submodule recursion revisited {}", canonical.display());
5036 }
5037 let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
5038 if !listed.is_empty() && !listed.ends_with(&[0]) {
5039 bail!(
5040 "git returned an unterminated untracked-file list for {}",
5041 repository.display()
5042 );
5043 }
5044
5045 for raw in listed
5046 .split(|byte| *byte == 0)
5047 .filter(|raw| !raw.is_empty())
5048 {
5049 let (relative, nested) = untracked_record(raw, "untracked")?;
5050 let from_root = prefix.join(&relative);
5051 if is_spar_marker(&from_root) {
5052 continue;
5053 }
5054 let absolute = root.join(&from_root);
5055 let fingerprint = if nested {
5056 nested_repository_fingerprint(&absolute)?
5057 } else {
5058 ignored_file_fingerprint(&absolute)?
5059 };
5060 if files.insert(from_root.clone(), fingerprint).is_some() {
5061 bail!(
5062 "git returned the untracked path more than once: {:?}",
5063 from_root
5064 );
5065 }
5066 }
5067
5068 let ignored_listed = run_git_bytes(
5069 repository,
5070 &[
5071 "ls-files",
5072 "--others",
5073 "--ignored",
5074 "--exclude-standard",
5075 "-z",
5076 ],
5077 )?;
5078 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5079 bail!(
5080 "git returned an unterminated ignored-file list for {}",
5081 repository.display()
5082 );
5083 }
5084 for raw in ignored_listed
5090 .split(|byte| *byte == 0)
5091 .filter(|raw| !raw.is_empty())
5092 {
5093 let (relative, nested) = untracked_record(raw, "ignored")?;
5094 let from_root = prefix.join(relative);
5095 if is_spar_marker(&from_root) {
5096 continue;
5097 }
5098 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5099 let absolute = root.join(&from_root);
5100 let fingerprint = if nested {
5101 nested_repository_fingerprint(&absolute)?
5102 } else {
5103 ignored_file_fingerprint(&absolute)?
5104 };
5105 slot.insert(fingerprint);
5106 }
5107 if !ignored.insert(from_root.clone()) {
5108 bail!(
5109 "git returned the ignored path more than once: {:?}",
5110 from_root
5111 );
5112 }
5113 }
5114
5115 for link in gitlinks(repository)? {
5116 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5117 continue;
5118 };
5119 collect_untracked_files(
5120 root,
5121 &submodule,
5122 &prefix.join(&link.path),
5123 visited,
5124 files,
5125 ignored,
5126 )?;
5127 }
5128 Ok(())
5129}
5130
5131fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5132 let argv = git_without_automation_argv(args);
5133 proc::run_bytes(
5134 &argv,
5135 &ExecOpts::new()
5136 .cwd(cwd)
5137 .timeout_secs(30)
5138 .stop_descendants(true),
5139 )
5140}
5141
5142fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5143 let argv = git_without_automation_argv(args);
5144 proc::run(
5145 &argv,
5146 &ExecOpts::new()
5147 .cwd(cwd)
5148 .timeout_secs(30)
5149 .stop_descendants(true),
5150 )
5151}
5152
5153fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5154 let path = path.to_str().ok_or_else(|| {
5155 spar_err!(
5156 "cannot verify filtered content for a non-UTF-8 path in {}",
5157 cwd.display()
5158 )
5159 })?;
5160 let path_arg = format!("--path={path}");
5161 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5162 Ok(Sha256::digest(bytes).into())
5163}
5164
5165fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5166 let relative = path_from_git_bytes(raw)?;
5167 if relative.is_absolute()
5168 || relative.components().any(|component| {
5169 matches!(
5170 component,
5171 std::path::Component::ParentDir
5172 | std::path::Component::RootDir
5173 | std::path::Component::Prefix(_)
5174 )
5175 })
5176 {
5177 bail!("git returned an unsafe {kind} path: {:?}", relative);
5178 }
5179 Ok(relative)
5180}
5181
5182fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5191 let nested = raw.last() == Some(&b'/');
5192 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5193 if trimmed.is_empty() {
5194 bail!("git returned an empty {kind} path");
5195 }
5196 Ok((safe_git_path(trimmed, kind)?, nested))
5197}
5198
5199fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5200 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5201 if !listed.is_empty() && !listed.ends_with(&[0]) {
5202 bail!(
5203 "git returned an unterminated index listing for {}",
5204 cwd.display()
5205 );
5206 }
5207 let mut entries = Vec::new();
5208 for record in listed
5209 .split(|byte| *byte == 0)
5210 .filter(|record| !record.is_empty())
5211 {
5212 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5213 bail!(
5214 "git returned a malformed index record for {}",
5215 cwd.display()
5216 );
5217 };
5218 let header = &record[..tab];
5219 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5220 if fields.len() != 3 {
5221 bail!(
5222 "git returned a malformed index header for {}",
5223 cwd.display()
5224 );
5225 }
5226 if fields[2] != b"0" {
5227 continue;
5228 }
5229 let mode = std::str::from_utf8(fields[0])
5230 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5231 .to_string();
5232 let oid = std::str::from_utf8(fields[1])
5233 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5234 .to_string();
5235 entries.push(IndexEntry {
5236 path: safe_git_path(&record[tab + 1..], "index")?,
5237 mode,
5238 oid,
5239 });
5240 }
5241 Ok(entries)
5242}
5243
5244fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5245 let untracked = run_git_bytes(
5246 cwd,
5247 &[
5248 "ls-files",
5249 "--others",
5250 "-z",
5251 "--",
5252 ".gitattributes",
5253 ":(glob)**/.gitattributes",
5254 ],
5255 )?;
5256 if !untracked.is_empty() {
5257 return Ok(true);
5258 }
5259
5260 let index = index_entries(cwd)?
5261 .into_iter()
5262 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5263 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5264 .collect::<BTreeMap<_, _>>();
5265 let head = tree_entries(cwd, "HEAD")?
5266 .into_iter()
5267 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5268 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5269 .collect::<BTreeMap<_, _>>();
5270 if index != head {
5271 return Ok(true);
5272 }
5273
5274 let effective = check_attributes(cwd, index.keys().cloned())?;
5275 let config = CheckoutConfig::read(cwd)?;
5276 for (path, (_mode, oid)) in index {
5277 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5278 return Ok(true);
5279 };
5280 let attributes = effective
5281 .get(&path)
5282 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5283 if allows_expected_crlf(&config, attributes)? {
5284 if worktree.mode == "120000" {
5285 return Ok(true);
5286 }
5287 let (normalized, every_lf_was_crlf) =
5288 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5289 if !every_lf_was_crlf || normalized != oid {
5290 return Ok(true);
5291 }
5292 } else if worktree.raw_oid != oid {
5293 return Ok(true);
5294 }
5295 }
5296 Ok(false)
5297}
5298
5299fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5300 Ok(index_entries(cwd)?
5301 .into_iter()
5302 .filter(|entry| entry.mode == "160000")
5303 .map(|entry| Gitlink {
5304 path: entry.path,
5305 oid: entry.oid,
5306 })
5307 .collect())
5308}
5309
5310fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5311 let mut tracked = BTreeMap::new();
5312 for entry in index_entries(cwd)? {
5313 if entry.mode == "160000" {
5314 continue;
5315 }
5316 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5317 tracked.insert(
5318 entry.path,
5319 TrackedEntry {
5320 index_mode: entry.mode,
5321 index_oid: entry.oid,
5322 worktree,
5323 },
5324 );
5325 }
5326 Ok(tracked)
5327}
5328
5329fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5330 let metadata = match std::fs::symlink_metadata(path) {
5331 Ok(metadata) => metadata,
5332 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5333 Err(e) => {
5334 return Err(spar_err!(
5335 "could not inspect tracked file {}: {e}",
5336 path.display()
5337 ))
5338 }
5339 };
5340 let mut fingerprint = Sha256::new();
5341 if metadata.file_type().is_symlink() {
5342 let target = std::fs::read_link(path)
5343 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5344 let bytes = os_str_bytes(target.as_os_str())?;
5345 fingerprint.update(b"symlink\0");
5346 fingerprint.update(&bytes);
5347 let content = Sha256::digest(&bytes).into();
5348 return Ok(Some(WorktreeFile {
5349 mode: "120000".to_string(),
5350 #[cfg(unix)]
5351 permissions: 0,
5352 raw_oid: git_blob_oid(oid_len, &bytes)?,
5353 fingerprint: fingerprint.finalize().into(),
5354 content,
5355 }));
5356 }
5357 if !metadata.is_file() {
5358 bail!("tracked path {} is not a file or symlink", path.display());
5359 }
5360
5361 let mut options = OpenOptions::new();
5362 options.read(true);
5363 #[cfg(unix)]
5364 {
5365 use std::os::unix::fs::OpenOptionsExt;
5366 options.custom_flags(libc::O_NOFOLLOW);
5367 }
5368 let mut file = options
5369 .open(path)
5370 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5371 let before = file
5372 .metadata()
5373 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5374 let mode = tracked_file_mode(&before);
5375 #[cfg(unix)]
5376 let permissions = {
5377 use std::os::unix::fs::MetadataExt;
5378 before.mode() & 0o7777
5379 };
5380 fingerprint.update(b"file\0");
5381 fingerprint.update(mode.as_bytes());
5382 #[cfg(unix)]
5383 fingerprint.update(permissions.to_le_bytes());
5384 fingerprint.update(before.len().to_le_bytes());
5385 let mut content = Sha256::new();
5386 let header = format!("blob {}\0", before.len());
5387 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5388 let mut buf = [0u8; 64 * 1024];
5389 loop {
5390 let read = file
5391 .read(&mut buf)
5392 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5393 if read == 0 {
5394 break;
5395 }
5396 fingerprint.update(&buf[..read]);
5397 content.update(&buf[..read]);
5398 object.update(&buf[..read]);
5399 }
5400 let after = file
5401 .metadata()
5402 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5403 if before.len() != after.len()
5404 || before.modified().ok() != after.modified().ok()
5405 || before.permissions() != after.permissions()
5406 {
5407 bail!(
5408 "tracked file {} changed while it was being inspected",
5409 path.display()
5410 );
5411 }
5412 let current = std::fs::symlink_metadata(path)
5413 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5414 if !same_file(&after, ¤t) {
5415 bail!(
5416 "tracked file {} was replaced while it was being inspected",
5417 path.display()
5418 );
5419 }
5420 Ok(Some(WorktreeFile {
5421 mode,
5422 #[cfg(unix)]
5423 permissions,
5424 raw_oid: object.finish(),
5425 fingerprint: fingerprint.finalize().into(),
5426 content: content.finalize().into(),
5427 }))
5428}
5429
5430fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5431 let metadata = std::fs::symlink_metadata(path)
5432 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5433 let mut digest = Sha256::new();
5434 if metadata.file_type().is_symlink() {
5435 digest.update(b"symlink\0");
5436 let target = std::fs::read_link(path)
5437 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5438 digest.update(os_str_bytes(target.as_os_str())?);
5439 return Ok(digest.finalize().into());
5440 }
5441 if !metadata.is_file() {
5442 bail!("attribute path {} is not a file or symlink", path.display());
5443 }
5444 let mut options = OpenOptions::new();
5445 options.read(true);
5446 #[cfg(unix)]
5447 {
5448 use std::os::unix::fs::OpenOptionsExt;
5449 options.custom_flags(libc::O_NOFOLLOW);
5450 }
5451 let mut file = options
5452 .open(path)
5453 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5454 let before = file
5455 .metadata()
5456 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5457 digest.update(b"file\0");
5458 let mut buf = [0u8; 64 * 1024];
5459 loop {
5460 let read = file
5461 .read(&mut buf)
5462 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5463 if read == 0 {
5464 break;
5465 }
5466 digest.update(&buf[..read]);
5467 }
5468 let after = file
5469 .metadata()
5470 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5471 let current = std::fs::symlink_metadata(path)
5472 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5473 if before.len() != after.len()
5474 || before.modified().ok() != after.modified().ok()
5475 || !same_file(&after, ¤t)
5476 {
5477 bail!(
5478 "attribute file {} changed while it was being inspected",
5479 path.display()
5480 );
5481 }
5482 Ok(digest.finalize().into())
5483}
5484
5485enum ObjectHasher {
5486 Sha1(Sha1),
5487 Sha256(Sha256),
5488}
5489
5490impl ObjectHasher {
5491 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5492 let mut hasher = match oid_len {
5493 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5494 64 => Self::Sha256(Sha256::new()),
5495 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5496 };
5497 hasher.update(header);
5498 Ok(hasher)
5499 }
5500
5501 fn update(&mut self, bytes: &[u8]) {
5502 match self {
5503 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5504 Self::Sha256(hasher) => hasher.update(bytes),
5505 }
5506 }
5507
5508 fn finish(self) -> String {
5509 let bytes = match self {
5510 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
5511 Self::Sha256(hasher) => hasher.finalize().to_vec(),
5512 };
5513 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
5514 }
5515}
5516
5517fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
5518 let header = format!("blob {}\0", bytes.len());
5519 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
5520 hasher.update(bytes);
5521 Ok(hasher.finish())
5522}
5523
5524fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
5525 let mut first = open_regular_file(path)?;
5526 let first_before = first
5527 .metadata()
5528 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5529 let mut raw_len = 0u64;
5530 let mut crlf_pairs = 0u64;
5531 let mut previous_was_cr = false;
5532 let mut every_lf_was_crlf = true;
5533 let mut buf = [0u8; 64 * 1024];
5534 loop {
5535 let read = first
5536 .read(&mut buf)
5537 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5538 if read == 0 {
5539 break;
5540 }
5541 raw_len = raw_len
5542 .checked_add(read as u64)
5543 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5544 for byte in &buf[..read] {
5545 if *byte == b'\n' {
5546 if previous_was_cr {
5547 crlf_pairs += 1;
5548 } else {
5549 every_lf_was_crlf = false;
5550 }
5551 }
5552 previous_was_cr = *byte == b'\r';
5553 }
5554 }
5555 let first_after = first
5556 .metadata()
5557 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5558 let current = std::fs::symlink_metadata(path)
5559 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5560 if raw_len != first_before.len()
5561 || !stable_file_metadata(&first_before, &first_after)
5562 || !stable_file_metadata(&first_after, ¤t)
5563 {
5564 bail!(
5565 "tracked file {} changed while line endings were inspected",
5566 path.display()
5567 );
5568 }
5569
5570 let normalized_len = raw_len
5571 .checked_sub(crlf_pairs)
5572 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5573 let header = format!("blob {normalized_len}\0");
5574 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5575 let mut second = open_regular_file(path)?;
5576 let second_before = second
5577 .metadata()
5578 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5579 if !stable_file_metadata(&first_after, &second_before) {
5580 bail!(
5581 "tracked file {} changed between line-ending checks",
5582 path.display()
5583 );
5584 }
5585 let mut pending_cr = false;
5586 loop {
5587 let read = second
5588 .read(&mut buf)
5589 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5590 if read == 0 {
5591 break;
5592 }
5593 for byte in &buf[..read] {
5594 if pending_cr {
5595 if *byte == b'\n' {
5596 object.update(b"\n");
5597 pending_cr = false;
5598 continue;
5599 }
5600 object.update(b"\r");
5601 pending_cr = false;
5602 }
5603 if *byte == b'\r' {
5604 pending_cr = true;
5605 } else {
5606 object.update(std::slice::from_ref(byte));
5607 }
5608 }
5609 }
5610 if pending_cr {
5611 object.update(b"\r");
5612 }
5613 let second_after = second
5614 .metadata()
5615 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5616 let current = std::fs::symlink_metadata(path)
5617 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5618 if !stable_file_metadata(&second_before, &second_after)
5619 || !stable_file_metadata(&second_after, ¤t)
5620 {
5621 bail!(
5622 "tracked file {} changed while line endings were hashed",
5623 path.display()
5624 );
5625 }
5626 Ok((object.finish(), every_lf_was_crlf))
5627}
5628
5629fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5630 let mut options = OpenOptions::new();
5631 options.read(true);
5632 #[cfg(unix)]
5633 {
5634 use std::os::unix::fs::OpenOptionsExt;
5635 options.custom_flags(libc::O_NOFOLLOW);
5636 }
5637 let file = options
5638 .open(path)
5639 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5640 let metadata = file
5641 .metadata()
5642 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5643 if !metadata.is_file() {
5644 bail!("tracked path {} is not a regular file", path.display());
5645 }
5646 Ok(file)
5647}
5648
5649fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5650 if !same_file(left, right)
5651 || left.len() != right.len()
5652 || left.modified().ok() != right.modified().ok()
5653 || left.permissions() != right.permissions()
5654 {
5655 return false;
5656 }
5657 #[cfg(unix)]
5658 {
5659 use std::os::unix::fs::MetadataExt;
5660 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5661 }
5662 #[cfg(not(unix))]
5663 {
5664 left.created().ok() == right.created().ok()
5665 }
5666}
5667
5668const ATTRIBUTE_BATCH: usize = 1000;
5675
5676fn check_attributes(
5677 cwd: &Path,
5678 paths: impl IntoIterator<Item = PathBuf>,
5679) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5680 let paths = paths.into_iter().collect::<BTreeSet<_>>();
5681 if paths.is_empty() {
5682 return Ok(BTreeMap::new());
5683 }
5684 let ordered = paths.into_iter().collect::<Vec<_>>();
5685 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5686 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
5687 values.extend(check_attribute_batch(cwd, batch)?);
5688 }
5689 Ok(values)
5690}
5691
5692fn check_attribute_batch(
5693 cwd: &Path,
5694 batch: &[PathBuf],
5695) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5696 const NAMES: [&str; 6] = [
5697 "filter",
5698 "working-tree-encoding",
5699 "ident",
5700 "text",
5701 "eol",
5702 "crlf",
5703 ];
5704 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
5705 let mut input = String::new();
5706 for path in &paths {
5707 let path = path.to_str().ok_or_else(|| {
5708 spar_err!(
5709 "cannot inspect attributes for a non-UTF-8 path in {}",
5710 cwd.display()
5711 )
5712 })?;
5713 input.push_str(path);
5714 input.push('\0');
5715 }
5716 let argv = git_without_automation_argv(&[
5717 "check-attr",
5718 "-z",
5719 "--cached",
5720 "--stdin",
5721 "filter",
5722 "working-tree-encoding",
5723 "ident",
5724 "text",
5725 "eol",
5726 "crlf",
5727 ]);
5728 let output = proc::run_bytes(
5729 &argv,
5730 &ExecOpts::new()
5731 .cwd(cwd)
5732 .timeout_secs(30)
5733 .stdin(input)
5734 .stop_descendants(true),
5735 )?;
5736 if !output.is_empty() && !output.ends_with(&[0]) {
5737 bail!(
5738 "git returned an unterminated attribute result for {}",
5739 cwd.display()
5740 );
5741 }
5742 let fields = output
5743 .split(|byte| *byte == 0)
5744 .filter(|field| !field.is_empty())
5745 .collect::<Vec<_>>();
5746 if fields.len() != paths.len() * NAMES.len() * 3 {
5747 bail!(
5748 "git returned an unexpected attribute result for {}",
5749 cwd.display()
5750 );
5751 }
5752 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5753 for record in fields.chunks_exact(3) {
5754 let path = safe_git_path(record[0], "attribute")?;
5755 if !paths.contains(&path) {
5756 bail!(
5757 "git returned attributes for the wrong path in {}",
5758 cwd.display()
5759 );
5760 }
5761 let name = std::str::from_utf8(record[1])
5762 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
5763 let value = std::str::from_utf8(record[2])
5764 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
5765 values
5766 .entry(path)
5767 .or_default()
5768 .insert(name.to_string(), value.to_string());
5769 }
5770 if paths.iter().any(|path| {
5771 values
5772 .get(path)
5773 .is_none_or(|attributes| attributes.len() != NAMES.len())
5774 }) {
5775 bail!(
5776 "git omitted an attribute result for a tracked path in {}",
5777 cwd.display()
5778 );
5779 }
5780 Ok(values)
5781}
5782
5783fn attribute_is_active(value: Option<&String>) -> bool {
5784 !matches!(
5785 value.map(String::as_str),
5786 None | Some("unspecified") | Some("unset")
5787 )
5788}
5789
5790fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
5791 attribute_is_active(values.get("filter"))
5792 || attribute_is_active(values.get("working-tree-encoding"))
5793}
5794
5795struct CheckoutConfig {
5804 autocrlf: Option<String>,
5805 eol: Option<String>,
5806 symlinks: Option<bool>,
5807}
5808
5809impl CheckoutConfig {
5810 fn read(cwd: &Path) -> Result<Self> {
5811 Ok(Self {
5812 autocrlf: git_config_value(cwd, "core.autocrlf")?,
5813 eol: git_config_value(cwd, "core.eol")?,
5814 symlinks: git_config_bool(cwd, "core.symlinks")?,
5815 })
5816 }
5817
5818 fn autocrlf_is_true(&self) -> bool {
5819 self.autocrlf.as_ref().is_some_and(|value| {
5820 matches!(
5821 value.to_ascii_lowercase().as_str(),
5822 "true" | "yes" | "on" | "1"
5823 )
5824 })
5825 }
5826
5827 fn eol_is(&self, wanted: &str) -> bool {
5828 self.eol
5829 .as_ref()
5830 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
5831 }
5832}
5833
5834fn path_has_ambiguous_transform(
5835 config: &CheckoutConfig,
5836 values: &BTreeMap<String, String>,
5837) -> Result<bool> {
5838 if path_has_external_transform(values)
5839 || attribute_is_active(values.get("ident"))
5840 || attribute_is_active(values.get("crlf"))
5841 {
5842 return Ok(true);
5843 }
5844 let text = values.get("text").map(String::as_str);
5845 let eol = values.get("eol").map(String::as_str);
5846 if text == Some("auto") {
5847 return Ok(true);
5848 }
5849 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
5850 || !matches!(
5851 eol,
5852 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
5853 )
5854 {
5855 return Ok(true);
5856 }
5857 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
5858 return Ok(config.autocrlf_is_true());
5859 }
5860 Ok(false)
5861}
5862
5863fn allows_expected_crlf(
5864 config: &CheckoutConfig,
5865 values: &BTreeMap<String, String>,
5866) -> Result<bool> {
5867 if path_has_external_transform(values)
5868 || attribute_is_active(values.get("ident"))
5869 || attribute_is_active(values.get("crlf"))
5870 {
5871 return Ok(false);
5872 }
5873 let text = values.get("text").map(String::as_str);
5874 let eol = values.get("eol").map(String::as_str);
5875 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
5876 return Ok(false);
5877 }
5878 if eol == Some("crlf") {
5879 return Ok(true);
5880 }
5881 if text != Some("set") {
5882 return Ok(false);
5883 }
5884 if let Some(autocrlf) = config.autocrlf.as_deref() {
5885 match autocrlf.to_ascii_lowercase().as_str() {
5886 "true" | "yes" | "on" | "1" => return Ok(true),
5887 "input" => return Ok(false),
5888 _ => {}
5889 }
5890 }
5891 if config.eol_is("crlf") {
5892 return Ok(true);
5893 }
5894 #[cfg(windows)]
5895 if config.eol.is_none() || config.eol_is("native") {
5896 return Ok(true);
5897 }
5898 Ok(false)
5899}
5900
5901fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
5902 let argv = git_without_automation_argv(&["config", "--get", key]);
5903 let output = proc::exec(
5904 &argv,
5905 &ExecOpts::new()
5906 .cwd(cwd)
5907 .timeout_secs(30)
5908 .check(false)
5909 .stop_descendants(true),
5910 )?;
5911 match output.code {
5912 0 => Ok(Some(output.stdout.trim().to_string())),
5913 1 => Ok(None),
5914 _ => bail!(
5915 "could not read Git configuration in {}: {}",
5916 cwd.display(),
5917 output.stderr.trim()
5918 ),
5919 }
5920}
5921
5922fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
5923 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
5924 let output = proc::exec(
5925 &argv,
5926 &ExecOpts::new()
5927 .cwd(cwd)
5928 .timeout_secs(30)
5929 .check(false)
5930 .stop_descendants(true),
5931 )?;
5932 match output.code {
5933 0 if output.stdout.trim() == "true" => Ok(Some(true)),
5934 0 if output.stdout.trim() == "false" => Ok(Some(false)),
5935 0 => bail!(
5936 "git returned an invalid boolean for {key} in {}",
5937 cwd.display()
5938 ),
5939 1 => Ok(None),
5940 _ => bail!(
5941 "could not read Git configuration in {}: {}",
5942 cwd.display(),
5943 output.stderr.trim()
5944 ),
5945 }
5946}
5947
5948#[cfg(unix)]
5949fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
5950 use std::os::unix::fs::PermissionsExt;
5951 if metadata.permissions().mode() & 0o111 == 0 {
5952 "100644".to_string()
5953 } else {
5954 "100755".to_string()
5955 }
5956}
5957
5958#[cfg(not(unix))]
5959fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
5960 "100644".to_string()
5961}
5962
5963fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
5964 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
5965 if !listed.is_empty() && !listed.ends_with(&[0]) {
5966 bail!(
5967 "git returned an unterminated tree listing for {}",
5968 cwd.display()
5969 );
5970 }
5971 let mut entries = Vec::new();
5972 for record in listed
5973 .split(|byte| *byte == 0)
5974 .filter(|record| !record.is_empty())
5975 {
5976 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5977 bail!("git returned a malformed tree record for {}", cwd.display());
5978 };
5979 let fields = record[..tab]
5980 .split(|byte| *byte == b' ')
5981 .collect::<Vec<_>>();
5982 if fields.len() != 3 {
5983 bail!("git returned a malformed tree header for {}", cwd.display());
5984 }
5985 let mode = std::str::from_utf8(fields[0])
5986 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
5987 .to_string();
5988 let oid = std::str::from_utf8(fields[2])
5989 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5990 .to_string();
5991 entries.push(IndexEntry {
5992 path: safe_git_path(&record[tab + 1..], "tree")?,
5993 mode,
5994 oid,
5995 });
5996 }
5997 Ok(entries)
5998}
5999
6000fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6001 Ok(tree_entries(cwd, "HEAD")?
6002 .into_iter()
6003 .filter(|entry| entry.mode == "160000")
6004 .map(|entry| (entry.path, entry.oid))
6005 .collect())
6006}
6007
6008fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6009 let head = head_gitlinks(cwd)?;
6010 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6011 .into_iter()
6012 .map(|link| (link.path, link.oid))
6013 .collect();
6014 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6015 paths.extend(index.keys().cloned());
6016 Ok(paths
6017 .into_iter()
6018 .filter(|path| head.get(path) != index.get(path))
6019 .collect())
6020}
6021
6022fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6023 let path = parent.join(relative);
6024 let metadata = match std::fs::symlink_metadata(&path) {
6025 Ok(metadata) => metadata,
6026 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6027 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6028 };
6029 if !metadata.is_dir() {
6030 bail!("the gitlink at {} is not a directory", path.display());
6031 }
6032 let canonical = std::fs::canonicalize(&path)
6033 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6034 if canonical != path {
6035 bail!(
6036 "the gitlink at {} resolves through a symlink",
6037 path.display()
6038 );
6039 }
6040 if !path.join(".git").exists() {
6041 let empty = std::fs::read_dir(&path)
6042 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6043 .next()
6044 .is_none();
6045 if empty {
6046 return Ok(None);
6047 }
6048 bail!(
6049 "the uninitialized gitlink at {} contains local files",
6050 path.display()
6051 );
6052 }
6053 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6054 if inside.trim() != "true" {
6055 bail!("the gitlink at {} is not a worktree", path.display());
6056 }
6057 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6058 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6059 spar_err!(
6060 "could not resolve the gitlink top level at {}: {e}",
6061 path.display()
6062 )
6063 })?;
6064 if top != canonical {
6065 bail!(
6066 "the gitlink at {} belongs to a different worktree",
6067 path.display()
6068 );
6069 }
6070 Ok(Some(canonical))
6071}
6072
6073fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6087 let root = std::fs::canonicalize(cwd)
6088 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6089 let mut allowed = BTreeSet::from([root.join(".git")]);
6090 let mut checkouts = BTreeSet::new();
6091 let mut repositories = vec![root.clone()];
6092 let mut visited = BTreeSet::new();
6093 while let Some(repository) = repositories.pop() {
6094 let canonical = std::fs::canonicalize(&repository)
6095 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6096 if !visited.insert(canonical.clone()) {
6097 bail!("submodule recursion revisited {}", canonical.display());
6098 }
6099 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6100 let (path, nested) = untracked_record(&raw, "untracked")?;
6101 if nested {
6102 checkouts.insert(canonical.join(path));
6103 }
6104 }
6105 for link in gitlinks(&canonical)? {
6106 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6107 continue;
6108 };
6109 allowed.insert(submodule.join(".git"));
6110 repositories.push(submodule);
6111 }
6112 }
6113
6114 let scan_root = root.clone();
6118 let mut directories = vec![root];
6119 while let Some(directory) = directories.pop() {
6120 let entries = match std::fs::read_dir(&directory) {
6121 Ok(entries) => entries,
6122 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6123 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6124 };
6125 for entry in entries {
6126 let entry = match entry {
6127 Ok(entry) => entry,
6128 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6129 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6130 };
6131 let path = entry.path();
6132 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6133 continue;
6134 }
6135 if entry.file_name() == OsStr::new(".git") {
6136 if !allowed.contains(&path) {
6137 return Ok(Some(path));
6138 }
6139 continue;
6140 }
6141 let kind = match entry.file_type() {
6142 Ok(kind) => kind,
6143 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6144 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6145 };
6146 if kind.is_dir() && !checkouts.contains(&path) {
6147 directories.push(path);
6148 }
6149 }
6150 }
6151 Ok(None)
6152}
6153
6154pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6155 if let Some(path) = unexpected_nested_git_entry(cwd)? {
6156 bail!(
6157 "the worktree contains a Git entry at {} that Git does not report as a nested \
6158 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6159 prove they are unchanged.",
6160 path.display()
6161 );
6162 }
6163 let root = std::fs::canonicalize(cwd)
6164 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6165 let mut repositories = BTreeMap::new();
6166 let mut visited = BTreeSet::new();
6167 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
6168 Ok(GitState { repositories })
6169}
6170
6171fn collect_git_state(
6172 repository: &Path,
6173 prefix: &Path,
6174 visited: &mut BTreeSet<PathBuf>,
6175 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
6176) -> Result<()> {
6177 let canonical = std::fs::canonicalize(repository)
6178 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6179 if !visited.insert(canonical.clone()) {
6180 bail!("submodule recursion revisited {}", canonical.display());
6181 }
6182 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6183 let head = head.trim().to_string();
6184 if head.is_empty() {
6185 bail!("git returned an empty head for {}", repository.display());
6186 }
6187 let unsafe_index_flags = unsafe_index_flags(repository)?;
6188 let tracked = tracked_entries(repository)?;
6189 let gitlinks = gitlinks(repository)?;
6190 if repositories
6191 .insert(
6192 prefix.to_path_buf(),
6193 RepositoryState {
6194 head,
6195 unsafe_index_flags,
6196 tracked,
6197 gitlinks: gitlinks
6198 .iter()
6199 .map(|link| (link.path.clone(), link.oid.clone()))
6200 .collect(),
6201 },
6202 )
6203 .is_some()
6204 {
6205 bail!("Git state contains duplicate repository path {:?}", prefix);
6206 }
6207
6208 for link in gitlinks {
6209 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6210 continue;
6211 };
6212 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6213 }
6214 Ok(())
6215}
6216
6217fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6218 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6219 if !listed.is_empty() && !listed.ends_with(&[0]) {
6220 bail!(
6221 "git returned an unterminated index-flag listing for {}",
6222 cwd.display()
6223 );
6224 }
6225 let mut unsafe_records = Vec::new();
6226 for record in listed
6227 .split(|byte| *byte == 0)
6228 .filter(|record| !record.is_empty())
6229 {
6230 if record.len() < 3 || record[1] != b' ' {
6231 bail!(
6232 "git returned a malformed index-flag record for {}",
6233 cwd.display()
6234 );
6235 }
6236 if record[0] != b'H' {
6237 unsafe_records.extend_from_slice(record);
6238 unsafe_records.push(0);
6239 }
6240 }
6241 Ok(unsafe_records)
6242}
6243
6244pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6245 safe_git_state(cwd).map(|_| ())
6246}
6247
6248pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6249 let state = git_state(cwd)?;
6250 if let Some((path, _repository)) = state
6251 .repositories
6252 .iter()
6253 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6254 {
6255 let label = if path.as_os_str().is_empty() {
6256 cwd.to_path_buf()
6257 } else {
6258 cwd.join(path)
6259 };
6260 bail!(
6261 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6262 SPAR cannot prove the working files are unchanged, so it was kept.",
6263 label.display()
6264 );
6265 }
6266 Ok(state)
6267}
6268
6269fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6270 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
6271}
6272
6273fn repository_has_recoverable_work_with(
6274 cwd: &Path,
6275 include_ignored: bool,
6276 disposable: Disposable,
6277) -> Result<bool> {
6278 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6279 return Ok(true);
6280 }
6281 let mut visited = BTreeSet::new();
6282 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
6283}
6284
6285fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6286 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6287 let git_dir = PathBuf::from(git_dir.trim());
6288 let git_dir = if git_dir.is_absolute() {
6289 git_dir
6290 } else {
6291 cwd.join(git_dir)
6292 };
6293 let git_dir = std::fs::canonicalize(&git_dir)
6294 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6295 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6296 Ok(_) => return Ok(true),
6297 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6298 Err(error) => {
6299 return Err(spar_err!(
6300 "could not inspect per-worktree configuration in {}: {error}",
6301 git_dir.display()
6302 ))
6303 }
6304 }
6305
6306 let orig_head = git_dir.join("ORIG_HEAD");
6307 match std::fs::symlink_metadata(&orig_head) {
6308 Ok(metadata) if metadata.is_file() => {
6309 let oid = std::fs::read_to_string(&orig_head)
6310 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6311 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6312 return Ok(true);
6313 };
6314 if !commit_has_shared_ref(cwd, &commit)? {
6315 return Ok(true);
6316 }
6317 }
6318 Ok(_) => return Ok(true),
6319 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6320 Err(error) => {
6321 return Err(spar_err!(
6322 "could not inspect {}: {error}",
6323 orig_head.display()
6324 ))
6325 }
6326 }
6327
6328 let edit_message = git_dir.join("COMMIT_EDITMSG");
6329 match std::fs::symlink_metadata(&edit_message) {
6330 Ok(metadata) if metadata.is_file() => {
6331 let draft = std::fs::read(&edit_message)
6332 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6333 if draft != head_commit_message(cwd)? {
6334 return Ok(true);
6335 }
6336 }
6337 Ok(_) => return Ok(true),
6338 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6339 Err(error) => {
6340 return Err(spar_err!(
6341 "could not inspect {}: {error}",
6342 edit_message.display()
6343 ))
6344 }
6345 }
6346
6347 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6348 return Ok(true);
6349 }
6350
6351 let local_refs = run_git_bytes(
6352 cwd,
6353 &[
6354 "for-each-ref",
6355 "--format=%(refname)",
6356 "refs/worktree",
6357 "refs/bisect",
6358 "refs/rewritten",
6359 ],
6360 )?;
6361 if !local_refs.is_empty() {
6362 return Ok(true);
6363 }
6364
6365 for entry in std::fs::read_dir(&git_dir)
6366 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6367 {
6368 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6369 let known = matches!(
6370 entry.file_name().to_str(),
6371 Some(
6372 "HEAD"
6373 | "ORIG_HEAD"
6374 | "COMMIT_EDITMSG"
6375 | "commondir"
6376 | "gitdir"
6377 | "index"
6378 | "logs"
6379 | "refs"
6380 )
6381 );
6382 if !known {
6383 return Ok(true);
6384 }
6385 }
6386
6387 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6388 if !commit_has_shared_ref(cwd, head.trim())? {
6389 return Ok(true);
6390 }
6391 Ok(false)
6392}
6393
6394fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6395 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6396 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6397 bail!(
6398 "git returned a commit without a message separator in {}",
6399 cwd.display()
6400 );
6401 };
6402 Ok(commit[split + 2..].to_vec())
6403}
6404
6405fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6406 let metadata = match std::fs::symlink_metadata(logs) {
6407 Ok(metadata) => metadata,
6408 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6409 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6410 };
6411 if !metadata.is_dir() {
6412 return Ok(true);
6413 }
6414 let mut files = Vec::new();
6415 let mut directories = vec![logs.to_path_buf()];
6416 while let Some(directory) = directories.pop() {
6417 for entry in std::fs::read_dir(&directory)
6418 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6419 {
6420 let entry =
6421 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6422 let path = entry.path();
6423 let kind = entry
6424 .file_type()
6425 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6426 if kind.is_dir() {
6427 directories.push(path);
6428 } else if kind.is_file() {
6429 files.push(path);
6430 } else {
6431 return Ok(true);
6432 }
6433 }
6434 }
6435
6436 let mut commits = BTreeSet::new();
6437 for path in files {
6438 if !collect_reflog_commits(cwd, &path, &mut commits)? {
6439 return Ok(true);
6440 }
6441 }
6442 for commit in commits {
6443 if !commit_has_shared_ref(cwd, &commit)? {
6444 return Ok(true);
6445 }
6446 }
6447 Ok(false)
6448}
6449
6450fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6454 let common = common_git_dir(cwd)?;
6455 let reflog = common.join("logs").join(refname);
6456 let metadata = match std::fs::symlink_metadata(&reflog) {
6457 Ok(metadata) => metadata,
6458 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6459 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6460 };
6461 if !metadata.is_file() {
6462 return Ok(false);
6463 }
6464 let mut commits = BTreeSet::new();
6465 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6466 return Ok(false);
6467 }
6468 for commit in commits {
6469 if is_ancestor(cwd, &commit, durable_tip)?
6470 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6471 {
6472 continue;
6473 }
6474 return Ok(false);
6475 }
6476 Ok(true)
6477}
6478
6479fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6480 let file = std::fs::File::open(path)
6481 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6482 for line in std::io::BufReader::new(file).lines() {
6483 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6484 let mut fields = line.splitn(3, ' ');
6485 let Some(old) = fields.next() else {
6486 return Ok(false);
6487 };
6488 let Some(new) = fields.next() else {
6489 return Ok(false);
6490 };
6491 if fields.next().is_none() {
6492 return Ok(false);
6493 }
6494 for oid in [old, new] {
6495 if oid.bytes().all(|byte| byte == b'0') {
6496 continue;
6497 }
6498 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6499 return Ok(false);
6500 };
6501 commits.insert(commit);
6502 }
6503 }
6504 Ok(true)
6505}
6506
6507fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6508 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6509 let path = PathBuf::from(raw.trim());
6510 let path = if path.is_absolute() {
6511 path
6512 } else {
6513 cwd.join(path)
6514 };
6515 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6516}
6517
6518fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6519 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6520 let output = proc::exec(
6521 &argv,
6522 &ExecOpts::new()
6523 .cwd(cwd)
6524 .timeout_secs(30)
6525 .check(false)
6526 .stop_descendants(true),
6527 )?;
6528 match output.code {
6529 0 => Ok(true),
6530 1 => Ok(false),
6531 _ => bail!("{}", proc::failure_message(&argv, &output)),
6532 }
6533}
6534
6535fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6536 let commit = format!("{oid}^{{commit}}");
6537 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6538 let output = proc::exec(
6539 &argv,
6540 &ExecOpts::new()
6541 .cwd(cwd)
6542 .timeout_secs(30)
6543 .check(false)
6544 .stop_descendants(true),
6545 )?;
6546 if output.code != 0 {
6547 return Ok(None);
6548 }
6549 let oid = output.stdout.trim();
6550 if oid.is_empty() {
6551 return Ok(None);
6552 }
6553 Ok(Some(oid.to_string()))
6554}
6555
6556fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6557 commit_has_shared_ref_except(cwd, oid, None)
6558}
6559
6560fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6561 let contains = format!("--contains={oid}");
6562 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6563 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6564 !record.is_empty()
6565 && !record.starts_with(b"refs/worktree/")
6566 && !record.starts_with(b"refs/bisect/")
6567 && !record.starts_with(b"refs/rewritten/")
6568 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6569 }))
6570}
6571
6572fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
6587 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6588 for raw in &ordinary {
6589 let (path, _) = untracked_record(raw, "untracked")?;
6590 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
6591 continue;
6592 }
6593 return Ok(true);
6594 }
6595 let listed = untracked_listing(
6596 cwd,
6597 &[
6598 "ls-files",
6599 "--others",
6600 "--ignored",
6601 "--exclude-standard",
6602 "-z",
6603 ],
6604 )?;
6605 for raw in listed {
6606 let (path, nested) = untracked_record(&raw, "ignored")?;
6607 if nested {
6611 return Ok(true);
6612 }
6613 if disposable == Disposable::BuildOutput && !is_generated_artifact(&path) {
6614 return Ok(true);
6615 }
6616 }
6617 Ok(false)
6618}
6619
6620fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6621 let listed = run_git_bytes(cwd, args)?;
6622 if !listed.is_empty() && !listed.ends_with(&[0]) {
6623 bail!(
6624 "git returned an unterminated untracked-file list for {}",
6625 cwd.display()
6626 );
6627 }
6628 Ok(listed
6629 .split(|byte| *byte == 0)
6630 .filter(|raw| !raw.is_empty())
6631 .map(|raw| raw.to_vec())
6632 .collect())
6633}
6634
6635fn repository_has_recoverable_work_inner(
6636 cwd: &Path,
6637 include_ignored: bool,
6638 disposable: Disposable,
6639 visited: &mut BTreeSet<PathBuf>,
6640) -> Result<bool> {
6641 let canonical = std::fs::canonicalize(cwd)
6642 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6643 if !visited.insert(canonical.clone()) {
6644 bail!("submodule recursion revisited {}", canonical.display());
6645 }
6646 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
6647 return Ok(true);
6648 }
6649 if !unsafe_index_flags(cwd)?.is_empty() {
6650 return Ok(true);
6651 }
6652 if attributes_may_be_modified(cwd)? {
6653 return Ok(true);
6654 }
6655 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6656 return Ok(true);
6657 }
6658 if include_ignored {
6659 let index = index_entries(cwd)?
6660 .into_iter()
6661 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6662 .collect::<BTreeMap<_, _>>();
6663 let head = tree_entries(cwd, "HEAD")?
6664 .into_iter()
6665 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6666 .collect::<BTreeMap<_, _>>();
6667 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6668 return Ok(true);
6669 }
6670 let tracked = tracked_entries(cwd)?;
6671 let effective = check_attributes(cwd, tracked.keys().cloned())?;
6672 let config = CheckoutConfig::read(cwd)?;
6673 for (path, entry) in tracked {
6674 let Some(worktree) = entry.worktree else {
6675 return Ok(true);
6676 };
6677 let attributes = effective.get(&path).ok_or_else(|| {
6678 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6679 })?;
6680 if path_has_ambiguous_transform(&config, attributes)? {
6681 return Ok(true);
6682 }
6683 let symlink_file = entry.index_mode == "120000"
6684 && worktree.mode == "100644"
6685 && worktree.raw_oid == entry.index_oid
6686 && config.symlinks == Some(false);
6687 if worktree.mode != entry.index_mode && !symlink_file {
6688 return Ok(true);
6689 }
6690 if entry.index_mode == "120000" {
6691 if worktree.raw_oid != entry.index_oid {
6692 return Ok(true);
6693 }
6694 continue;
6695 }
6696 #[cfg(unix)]
6697 {
6698 let expected = if entry.index_mode == "100755" {
6699 0o755
6700 } else {
6701 0o644
6702 };
6703 if worktree.permissions != expected {
6704 return Ok(true);
6705 }
6706 }
6707 if allows_expected_crlf(&config, attributes)? {
6708 let (normalized, every_lf_was_crlf) =
6709 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6710 if !every_lf_was_crlf || normalized != entry.index_oid {
6711 return Ok(true);
6712 }
6713 } else if worktree.raw_oid != entry.index_oid {
6714 return Ok(true);
6715 }
6716 }
6717 } else {
6718 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6719 if !run_git_bytes(cwd, &args)?.is_empty() {
6720 return Ok(true);
6721 }
6722 }
6723 for link in gitlinks(cwd)? {
6724 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6725 continue;
6726 };
6727 if include_ignored {
6732 return Ok(true);
6733 }
6734 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6735 if head.trim() != link.oid {
6736 return Ok(true);
6737 }
6738 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
6739 {
6740 return Ok(true);
6741 }
6742 }
6743 Ok(false)
6744}
6745
6746pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6747 repository_has_recoverable_work(cwd, false)
6748}
6749
6750fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6751 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6752 Ok(!run_git_bytes(cwd, &args)?.is_empty())
6753}
6754
6755#[cfg(unix)]
6756fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6757 use std::os::unix::ffi::OsStringExt;
6758 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6759}
6760
6761#[cfg(not(unix))]
6762fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6763 String::from_utf8(raw.to_vec())
6764 .map(PathBuf::from)
6765 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6766}
6767
6768fn vanished_untracked_file() -> UntrackedFile {
6784 UntrackedFile {
6785 kind: 0,
6786 len: 0,
6787 modified: None,
6788 created: None,
6789 readonly: false,
6790 symlink_target: None,
6791 #[cfg(unix)]
6792 device: 0,
6793 #[cfg(unix)]
6794 inode: 0,
6795 #[cfg(unix)]
6796 mode: 0,
6797 #[cfg(unix)]
6798 change_seconds: 0,
6799 #[cfg(unix)]
6800 change_nanoseconds: 0,
6801 }
6802}
6803
6804fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
6805 let metadata = match std::fs::symlink_metadata(path) {
6806 Ok(metadata) => metadata,
6807 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
6808 Err(e) => {
6809 return Err(spar_err!(
6810 "could not inspect the nested repository at {}: {e}",
6811 path.display()
6812 ))
6813 }
6814 };
6815 if !metadata.is_dir() {
6816 bail!(
6817 "git reported {} as a nested repository, but it is not a directory",
6818 path.display()
6819 );
6820 }
6821 if std::fs::symlink_metadata(path.join(".git")).is_err() {
6822 bail!(
6823 "git reported {} as a nested repository, but it has no Git entry",
6824 path.display()
6825 );
6826 }
6827 #[cfg(unix)]
6828 {
6829 use std::os::unix::fs::MetadataExt;
6830 Ok(UntrackedFile {
6831 kind: 3,
6832 len: 0,
6833 modified: None,
6834 created: metadata.created().ok(),
6835 readonly: metadata.permissions().readonly(),
6836 symlink_target: None,
6837 device: metadata.dev(),
6838 inode: metadata.ino(),
6839 mode: metadata.mode(),
6840 change_seconds: 0,
6841 change_nanoseconds: 0,
6842 })
6843 }
6844 #[cfg(not(unix))]
6845 {
6846 Ok(UntrackedFile {
6847 kind: 3,
6848 len: 0,
6849 modified: None,
6850 created: metadata.created().ok(),
6851 readonly: metadata.permissions().readonly(),
6852 symlink_target: None,
6853 })
6854 }
6855}
6856
6857fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
6858 let metadata = match std::fs::symlink_metadata(path) {
6859 Ok(metadata) => metadata,
6860 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
6861 Err(e) => {
6862 return Err(spar_err!(
6863 "could not inspect untracked file {}: {e}",
6864 path.display()
6865 ))
6866 }
6867 };
6868 let kind = if metadata.file_type().is_symlink() {
6869 2
6870 } else if metadata.is_file() {
6871 1
6872 } else {
6873 bail!(
6874 "untracked path {} is not a regular file or symlink",
6875 path.display()
6876 );
6877 };
6878 let symlink_target = if kind == 2 {
6879 match std::fs::read_link(path) {
6880 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
6881 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
6882 return Ok(vanished_untracked_file())
6883 }
6884 Err(e) => {
6885 return Err(spar_err!(
6886 "could not read untracked symlink {}: {e}",
6887 path.display()
6888 ))
6889 }
6890 }
6891 } else {
6892 None
6893 };
6894 #[cfg(unix)]
6895 {
6896 use std::os::unix::fs::MetadataExt;
6897 Ok(UntrackedFile {
6898 kind,
6899 len: metadata.len(),
6900 modified: metadata.modified().ok(),
6901 created: metadata.created().ok(),
6902 readonly: metadata.permissions().readonly(),
6903 symlink_target,
6904 device: metadata.dev(),
6905 inode: metadata.ino(),
6906 mode: metadata.mode(),
6907 change_seconds: metadata.ctime(),
6908 change_nanoseconds: metadata.ctime_nsec(),
6909 })
6910 }
6911 #[cfg(not(unix))]
6912 {
6913 Ok(UntrackedFile {
6914 kind,
6915 len: metadata.len(),
6916 modified: metadata.modified().ok(),
6917 created: metadata.created().ok(),
6918 readonly: metadata.permissions().readonly(),
6919 symlink_target,
6920 })
6921 }
6922}
6923
6924#[cfg(unix)]
6925fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6926 use std::os::unix::ffi::OsStrExt;
6927 Ok(value.as_bytes().to_vec())
6928}
6929
6930#[cfg(not(unix))]
6931fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6932 value
6933 .to_str()
6934 .map(|value| value.as_bytes().to_vec())
6935 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
6936}
6937
6938#[cfg(unix)]
6939fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6940 use std::os::unix::fs::MetadataExt;
6941 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
6942}
6943
6944#[cfg(not(unix))]
6945fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6946 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
6947}
6948
6949#[derive(Debug, Clone, serde::Serialize, Deserialize)]
6950pub struct BranchRecord {
6951 pub kind: String,
6952 pub number: i64,
6953}
6954
6955pub fn review_ref(number: i64) -> String {
6958 format!("refs/spar/pr-{number}")
6959}
6960
6961pub fn is_finished(state: &str) -> bool {
6962 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
6963}
6964
6965pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
6972 if let Some(parent) = path.parent() {
6973 std::fs::create_dir_all(parent)
6974 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6975 }
6976 let tmp = path.with_extension(format!(
6979 "{}.tmp",
6980 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
6981 ));
6982 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
6983 std::fs::rename(&tmp, path)
6984 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
6985 Ok(())
6986}
6987
6988pub fn has_heading(text: &str, title: &str) -> bool {
6995 let wanted = title.trim().to_lowercase();
6996 if wanted.is_empty() {
6997 return false;
6998 }
6999 text.lines()
7000 .filter_map(|line| line.trim().strip_prefix("## "))
7001 .any(|heading| heading.trim().to_lowercase() == wanted)
7002}
7003
7004pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7007 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7008}
7009
7010#[derive(Deserialize)]
7012#[serde(rename_all = "camelCase")]
7013pub struct LinkedPrRow {
7014 number: i64,
7015 #[serde(default)]
7016 url: String,
7017 #[serde(default)]
7018 title: String,
7019 #[serde(default)]
7020 closing_issues_references: Vec<IssueRef>,
7021}
7022
7023pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7030 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7031}
7032
7033pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7035 rows.iter()
7036 .find(|row| {
7037 row.closing_issues_references
7038 .iter()
7039 .any(|linked| linked.number == issue)
7040 })
7041 .map(|row| PrRef {
7042 number: row.number,
7043 url: row.url.clone(),
7044 title: row.title.clone(),
7045 })
7046}
7047
7048pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7050 pick_linked_pr(&linked_pr_rows(json)?, issue)
7051}
7052
7053fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7060 if text.trim().is_empty() {
7061 return Err(spar_err!("GitHub returned no comment data"));
7062 }
7063 let mut out = Vec::new();
7064 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7065 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7066 Value::Array(items) => out.extend(items),
7067 _ => return Err(spar_err!("unexpected non-array comment page")),
7068 }
7069 }
7070 Ok(out)
7071}
7072
7073pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7074 let mut out = Vec::new();
7075 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7076 match value {
7077 Ok(Value::Array(items)) => out.extend(items),
7078 Ok(other) => out.push(other),
7079 Err(_) => break,
7080 }
7081 }
7082 out
7083}
7084
7085pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7093 body.trim_start().starts_with(STATE_MARKER)
7094 && !viewer.trim().is_empty()
7095 && author.trim().eq_ignore_ascii_case(viewer.trim())
7096 && parse_state_comment(body).is_some()
7097}
7098
7099pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7102 let marker = body.find(STATE_MARKER)?;
7103 let start = body[marker..].find('{')? + marker;
7104 let end = body.rfind('}')?;
7105 if end <= start {
7106 return None;
7107 }
7108 match serde_json::from_str(&body[start..=end]) {
7109 Ok(state) => Some(state),
7110 Err(_) => {
7111 logdim!("found a spar state comment but could not parse it");
7112 None
7113 }
7114 }
7115}
7116
7117fn choose_state_for_head(
7118 candidates: Vec<PersistedState>,
7119 actual_head: &str,
7120) -> Option<PersistedState> {
7121 let matching: Vec<PersistedState> = candidates
7122 .iter()
7123 .filter(|state| state.pr_head == actual_head)
7124 .cloned()
7125 .collect();
7126 if !matching.is_empty() {
7127 return newest_state(matching);
7128 }
7129 newest_state(candidates)
7130}
7131
7132fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7133 candidates.into_iter().reduce(|best, candidate| {
7134 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7135 candidate
7136 } else {
7137 best
7142 }
7143 })
7144}
7145
7146pub fn self_binary() -> Result<PathBuf> {
7152 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7153 let path = PathBuf::from(path);
7154 if proc::is_executable(&path) {
7155 return Ok(path);
7156 }
7157 bail!(
7158 "SPAR_SELF_BIN is set to {}, which is not executable",
7159 path.display()
7160 );
7161 }
7162 std::env::current_exe()
7163 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
7164}
7165
7166fn bool_env(value: bool) -> &'static str {
7167 if value {
7168 "1"
7169 } else {
7170 "0"
7171 }
7172}
7173
7174pub fn sh_quote(text: &str) -> String {
7177 format!("'{}'", text.replace('\'', r"'\''"))
7178}
7179
7180pub fn style_from_env() -> Style {
7183 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
7184 Style {
7185 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
7186 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
7187 ..Style::permissive()
7188 }
7189}
7190
7191#[cfg(test)]
7192mod tests {
7193 use super::*;
7194 use crate::config::StateStore;
7195 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
7196 use std::process::Command;
7197
7198 fn repo_for_titles() -> Repo {
7199 Repo {
7200 root: PathBuf::from("/nonexistent"),
7201 style: Style::default(),
7202 branch_prefix: String::new(),
7203 state_store: StateStore::Local,
7204 followups: crate::config::Followups::Issues,
7205 drafts: Drafts::Never,
7206 viewer: OnceLock::new(),
7207 checkpoints: Mutex::new(BTreeMap::new()),
7208 writes: WriteStats::default(),
7209 }
7210 }
7211
7212 #[test]
7213 fn write_results_accumulate_for_the_run() {
7214 let repo = repo_for_titles();
7215
7216 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
7217 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
7218
7219 assert_eq!(
7220 WriteSummary {
7221 attempted: 2,
7222 failed: 1,
7223 },
7224 repo.write_summary()
7225 );
7226 }
7227
7228 #[test]
7229 fn only_failed_write_preflights_join_the_summary() {
7230 let repo = repo_for_titles();
7231
7232 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
7233 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
7234
7235 assert_eq!(
7236 WriteSummary {
7237 attempted: 1,
7238 failed: 1,
7239 },
7240 repo.write_summary()
7241 );
7242 }
7243
7244 #[test]
7245 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
7246 let repo = repo_for_titles();
7247
7248 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
7249 assert_eq!(
7250 WriteSummary {
7251 attempted: 1,
7252 failed: 1,
7253 },
7254 repo.write_summary()
7255 );
7256 }
7257
7258 #[test]
7259 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7260 let mut repo = repo_for_titles();
7261 repo.followups = Followups::Local;
7262
7263 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7264 assert_eq!(WriteSummary::default(), repo.write_summary());
7265 }
7266
7267 #[test]
7268 fn a_failed_remote_state_read_stops_before_state_mutation() {
7269 let root = std::env::temp_dir().join(format!(
7270 "spar-state-preflight-{}-{}",
7271 std::process::id(),
7272 std::time::SystemTime::now()
7273 .duration_since(std::time::UNIX_EPOCH)
7274 .unwrap()
7275 .as_nanos()
7276 ));
7277 std::fs::create_dir_all(&root).unwrap();
7278 let _fixture = ReviewFixture { root: root.clone() };
7279 let mut repo = repo_for_titles();
7280 repo.root = root;
7281 repo.state_store = StateStore::Both;
7282 let state = PersistedState {
7283 version: 1,
7284 checkpoint: 4,
7285 round: 2,
7286 next_actor: "a".into(),
7287 status: Status::Pending,
7288 pr_head: "abc123".into(),
7289 ledger: Ledger::new(),
7290 filed: Vec::new(),
7291 open_findings: Vec::new(),
7292 disputes: Vec::new(),
7293 noted: Vec::new(),
7294 };
7295
7296 let error = repo
7297 .write_state_after_remote_read(
7298 7,
7299 &state,
7300 Err(crate::error::SparError::new("state comments unavailable")),
7301 )
7302 .unwrap_err();
7303
7304 assert!(error.to_string().contains("state comments unavailable"));
7305 assert!(!repo.state_path(7).exists());
7306 assert_eq!(0, repo.remembered_checkpoint(7));
7307 assert_eq!(
7308 WriteSummary {
7309 attempted: 1,
7310 failed: 1,
7311 },
7312 repo.write_summary()
7313 );
7314 }
7315
7316 #[test]
7317 fn only_known_build_and_cache_directories_are_generated_artifacts() {
7318 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7319 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7320 assert!(is_generated_artifact(Path::new(
7321 "package/node_modules/dependency/file.js"
7322 )));
7323 assert!(!is_generated_artifact(Path::new(
7324 "distribution/required-package.js"
7325 )));
7326 assert!(!is_generated_artifact(Path::new(
7327 "generated/required-fixture.txt"
7328 )));
7329 assert!(!is_generated_artifact(Path::new("local.env")));
7330 }
7331
7332 struct ReviewFixture {
7333 root: PathBuf,
7334 }
7335
7336 impl Drop for ReviewFixture {
7337 fn drop(&mut self) {
7338 let _ = std::fs::remove_dir_all(&self.root);
7339 }
7340 }
7341
7342 fn test_git(cwd: &Path, args: &[&str]) -> String {
7343 let output = Command::new("git")
7344 .args(args)
7345 .current_dir(cwd)
7346 .output()
7347 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7348 assert!(
7349 output.status.success(),
7350 "git {args:?} failed: {}",
7351 String::from_utf8_lossy(&output.stderr)
7352 );
7353 String::from_utf8_lossy(&output.stdout).into_owned()
7354 }
7355
7356 fn review_fixture(
7357 tag: &str,
7358 number: i64,
7359 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7360 use std::sync::atomic::{AtomicU32, Ordering};
7361 static NEXT: AtomicU32 = AtomicU32::new(0);
7362 let id = NEXT.fetch_add(1, Ordering::Relaxed);
7363 let root =
7364 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7365 let origin = root.join("origin.git");
7366 let work = root.join("work");
7367 std::fs::create_dir_all(&origin).unwrap();
7368 std::fs::create_dir_all(&work).unwrap();
7369 test_git(&origin, &["init", "--bare", "-b", "main"]);
7370 test_git(&work, &["init", "-b", "main"]);
7371 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7372 test_git(&work, &["config", "user.name", "spar test"]);
7373 test_git(&work, &["config", "commit.gpgsign", "false"]);
7374 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7375 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7376 std::fs::write(work.join("README.md"), "seed\n").unwrap();
7377 std::fs::write(work.join("data.txt"), "old\n").unwrap();
7378 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7379 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7380 test_git(&work, &["add", "."]);
7381 test_git(&work, &["commit", "-m", "seed"]);
7382 test_git(
7383 &work,
7384 &["remote", "add", "origin", origin.to_str().unwrap()],
7385 );
7386 test_git(&work, &["push", "-u", "origin", "main"]);
7387 test_git(
7388 &work,
7389 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7390 );
7391 let cfg = crate::config::parse(
7392 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7393 )
7394 .unwrap();
7395 let repo = Repo::open(&work, &cfg).unwrap();
7396 let path = repo.worktree_for_pr_head(number).unwrap();
7397 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7398 (ReviewFixture { root }, repo, path, checkpoint)
7399 }
7400
7401 #[test]
7402 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7403 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7404
7405 repo.release_review_worktree_checked(901, &checkpoint)
7406 .unwrap();
7407
7408 assert!(!path.exists());
7409 }
7410
7411 #[test]
7412 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7413 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7414 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7415 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7416 test_git(&path, &["add", "recovery.txt"]);
7417 test_git(&path, &["commit", "-m", "recovery commit"]);
7418 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7419 test_git(&path, &["reset", "--hard", "main"]);
7420
7421 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7422 test_git(
7423 &path,
7424 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7425 );
7426 }
7427
7428 #[test]
7429 fn a_review_ref_reflog_only_commit_prevents_deletion() {
7430 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7431 let local_ref = review_ref(921);
7432 let original = test_git(&path, &["rev-parse", &local_ref]);
7433 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7434 let recovery = test_git(
7435 &path,
7436 &[
7437 "commit-tree",
7438 tree.trim(),
7439 "-p",
7440 original.trim(),
7441 "-m",
7442 "review ref recovery",
7443 ],
7444 );
7445 test_git(
7446 &path,
7447 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7448 );
7449 test_git(
7450 &path,
7451 &["update-ref", &local_ref, original.trim(), recovery.trim()],
7452 );
7453
7454 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7455 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7456 test_git(
7457 &path,
7458 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7459 );
7460 }
7461
7462 #[test]
7463 fn an_unpublished_commit_message_draft_is_recoverable() {
7464 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7465 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7466 let git_dir = if raw.is_absolute() {
7467 raw
7468 } else {
7469 path.join(raw)
7470 };
7471 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7472
7473 assert!(repository_has_recoverable_work(&path, true).unwrap());
7474 assert_eq!(
7475 "unique recovery draft\n",
7476 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7477 );
7478 }
7479
7480 #[test]
7481 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7482 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7483 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7484
7485 let error = repo
7486 .release_review_worktree_checked(902, &checkpoint)
7487 .unwrap_err();
7488
7489 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7490 assert!(error.to_string().contains("kept for recovery"), "{error}");
7491 assert_eq!(
7492 "recover me\n",
7493 std::fs::read_to_string(path.join("README.md")).unwrap()
7494 );
7495 repo.release_review_worktree(902);
7496 }
7497
7498 #[test]
7499 fn a_review_commit_is_retained_after_a_checked_read() {
7500 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7501 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7502 test_git(&path, &["add", "review-note.txt"]);
7503 test_git(&path, &["commit", "-m", "local review recovery"]);
7504 let head = test_git(&path, &["rev-parse", "HEAD"]);
7505
7506 let error = repo
7507 .release_review_worktree_checked(903, &checkpoint)
7508 .unwrap_err();
7509
7510 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7511 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7512 assert_eq!(
7513 "recover me\n",
7514 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7515 );
7516 repo.release_review_worktree(903);
7517 }
7518
7519 #[test]
7523 fn an_ignored_review_file_is_retained_after_a_checked_read() {
7524 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7525 std::fs::create_dir_all(path.join("generated")).unwrap();
7526 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7527
7528 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7529 .expect("its own output is not a change");
7530 let error = repo
7531 .release_review_worktree_checked(904, &checkpoint)
7532 .unwrap_err();
7533
7534 assert!(error.to_string().contains("recoverable"), "{error}");
7535 assert_eq!(
7536 "recover me\n",
7537 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7538 );
7539 repo.release_review_worktree(904);
7540 }
7541
7542 #[test]
7543 fn a_preexisting_ignored_review_file_change_is_retained() {
7544 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7545 std::fs::create_dir_all(path.join("generated")).unwrap();
7546 let ignored = path.join("generated/recovery.txt");
7547 std::fs::write(&ignored, "before\n").unwrap();
7548 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7549 std::fs::write(&ignored, "after!\n").unwrap();
7550
7551 let error = repo
7552 .release_review_worktree_checked(905, &checkpoint)
7553 .unwrap_err();
7554
7555 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7556 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7557 repo.release_review_worktree(905);
7558 }
7559
7560 #[test]
7561 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7562 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7563 std::fs::create_dir_all(path.join("generated")).unwrap();
7564 let ignored = path.join("generated/recovery.txt");
7565 std::fs::write(&ignored, "keep me\n").unwrap();
7566 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7567
7568 let error = repo
7569 .release_review_worktree_checked(906, &checkpoint)
7570 .unwrap_err();
7571
7572 assert!(error.to_string().contains("recoverable"), "{error}");
7573 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7574 }
7575
7576 #[test]
7577 fn overwriting_a_preexisting_untracked_file_is_detected() {
7578 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7579 let untracked = path.join("notes.txt");
7580 std::fs::write(&untracked, "before\n").unwrap();
7581 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7582 std::fs::write(&untracked, "after!\n").unwrap();
7583
7584 let error = repo
7585 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7586 .unwrap_err();
7587
7588 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7589 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7590 }
7591
7592 #[test]
7593 fn an_assume_unchanged_edit_is_detected() {
7594 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7595 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7596 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7597
7598 let error = repo
7599 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7600 .unwrap_err();
7601
7602 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7603 assert_eq!(
7604 "hidden\n",
7605 std::fs::read_to_string(path.join("README.md")).unwrap()
7606 );
7607 }
7608
7609 #[test]
7610 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7611 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7612 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7613 test_git(&path, &["add", "README.md"]);
7614 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7615
7616 let error = repo
7617 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7618 .unwrap_err();
7619
7620 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7621 assert_eq!(
7622 b"seed\r\n",
7623 std::fs::read(path.join("README.md")).unwrap().as_slice()
7624 );
7625 }
7626
7627 #[cfg(unix)]
7628 #[test]
7629 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7630 use std::os::unix::fs::PermissionsExt;
7631
7632 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7633 test_git(&path, &["config", "core.filemode", "false"]);
7634 let readme = path.join("README.md");
7635 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7636 permissions.set_mode(0o755);
7637 std::fs::set_permissions(&readme, permissions).unwrap();
7638 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7639
7640 let error = repo
7641 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7642 .unwrap_err();
7643
7644 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7645 assert_eq!(
7646 0o755,
7647 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7648 );
7649 }
7650
7651 #[test]
7652 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7653 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7654 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7655 test_git(&path, &["add", ".gitattributes"]);
7656 test_git(&path, &["commit", "-m", "select data filter"]);
7657 let baseline = repo.worktree_baseline(&path).unwrap();
7658 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7659
7660 assert!(repo
7661 .commit_pending_changes(&path, &baseline, "change data", "change data")
7662 .unwrap());
7663 let error = repo
7664 .refuse_unrepresented_tracked_changes(&path, &baseline)
7665 .unwrap_err();
7666
7667 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7668 assert_eq!(
7669 "secret: recover me\nnew\n",
7670 std::fs::read_to_string(path.join("data.txt")).unwrap()
7671 );
7672 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7673 }
7674
7675 #[test]
7676 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7677 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7678 std::fs::create_dir_all(path.join("target")).unwrap();
7679 let untracked = path.join("target/user.yaml");
7680 std::fs::write(&untracked, "user data\n").unwrap();
7681 let baseline = repo.worktree_baseline(&path).unwrap();
7682 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7683
7684 assert!(repo
7685 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7686 .unwrap());
7687
7688 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
7689 assert_eq!(
7690 "?? target/user.yaml\n",
7691 test_git(&path, &["status", "--short", "--untracked-files=all"])
7692 );
7693 assert!(test_git(
7694 &path,
7695 &[
7696 "ls-tree",
7697 "-r",
7698 "--name-only",
7699 "HEAD",
7700 "--",
7701 "target/user.yaml"
7702 ]
7703 )
7704 .is_empty());
7705 }
7706
7707 #[test]
7708 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
7709 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
7710 std::fs::create_dir_all(path.join("target")).unwrap();
7711 let untracked = path.join("target/user.yaml");
7712 std::fs::write(&untracked, "before\n").unwrap();
7713 let baseline = repo.worktree_baseline(&path).unwrap();
7714 let before = test_git(&path, &["rev-parse", "HEAD"]);
7715 std::fs::write(&untracked, "after\n").unwrap();
7716 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7717
7718 let error = repo
7719 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7720 .unwrap_err();
7721
7722 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7723 assert!(error.to_string().contains("target/user.yaml"), "{error}");
7724 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7725 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
7726 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
7727 }
7728
7729 #[test]
7730 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
7731 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
7732 let baseline = repo.worktree_baseline(&path).unwrap();
7733 std::fs::create_dir_all(path.join("target")).unwrap();
7734 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
7735
7736 assert!(repo
7737 .commit_pending_changes(&path, &baseline, "add file", "add file")
7738 .unwrap());
7739
7740 assert_eq!(
7741 "new file\n",
7742 test_git(&path, &["show", "HEAD:target/new.txt"])
7743 );
7744 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7745 }
7746
7747 #[test]
7748 fn deleting_existing_ignored_work_stops_a_managed_commit() {
7749 let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7750 std::fs::create_dir_all(path.join("generated")).unwrap();
7751 let ignored = path.join("generated/keep.txt");
7752 std::fs::write(&ignored, "user data\n").unwrap();
7753 let baseline = repo.worktree_baseline(&path).unwrap();
7754 let before = test_git(&path, &["rev-parse", "HEAD"]);
7755 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7756 std::fs::remove_file(&ignored).unwrap();
7757
7758 let error = repo
7759 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7760 .unwrap_err();
7761
7762 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7763 assert!(error.to_string().contains("existing untracked"), "{error}");
7764 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7765 assert_eq!(
7766 "tracked change\n",
7767 std::fs::read_to_string(path.join("README.md")).unwrap()
7768 );
7769 }
7770
7771 #[test]
7772 fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
7773 let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
7774 let baseline = repo.worktree_baseline(&path).unwrap();
7775 let before = test_git(&path, &["rev-parse", "HEAD"]);
7776 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7777 std::fs::create_dir_all(path.join("generated")).unwrap();
7778 let ignored = path.join("generated/recovery.txt");
7779 std::fs::write(&ignored, "keep me\n").unwrap();
7780
7781 let error = repo
7782 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7783 .unwrap_err();
7784
7785 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7786 assert!(error.to_string().contains("recovery.txt"), "{error}");
7787 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7788 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7789 assert!(test_git(&path, &["status", "--porcelain"])
7790 .lines()
7791 .any(|line| line == "M README.md"));
7792 }
7793
7794 #[test]
7795 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
7796 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
7797 test_git(&path, &["config", "core.autocrlf", "true"]);
7798 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7799 assert_eq!(
7800 test_git(&path, &["hash-object", "README.md"]).trim(),
7801 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7802 );
7803
7804 assert!(repository_has_recoverable_work(&path, true).unwrap());
7805 assert_eq!(
7806 "seed\n",
7807 std::fs::read_to_string(path.join("README.md")).unwrap()
7808 );
7809 }
7810
7811 #[test]
7812 fn autocrlf_input_overrides_a_crlf_core_eol() {
7813 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
7814 test_git(&path, &["config", "core.autocrlf", "input"]);
7815 test_git(&path, &["config", "core.eol", "crlf"]);
7816 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7817 assert_eq!(
7818 test_git(&path, &["hash-object", "README.md"]).trim(),
7819 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7820 );
7821
7822 assert!(repository_has_recoverable_work(&path, true).unwrap());
7823 assert_eq!(
7824 b"seed\r\n",
7825 std::fs::read(path.join("README.md")).unwrap().as_slice()
7826 );
7827 }
7828
7829 #[cfg(unix)]
7830 #[test]
7831 fn a_non_executable_permission_change_is_recoverable() {
7832 use std::os::unix::fs::PermissionsExt;
7833
7834 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
7835 let readme = path.join("README.md");
7836 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7837 permissions.set_mode(0o600);
7838 std::fs::set_permissions(&readme, permissions).unwrap();
7839 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7840
7841 let error = repo
7842 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7843 .unwrap_err();
7844
7845 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7846 assert!(repository_has_recoverable_work(&path, true).unwrap());
7847 assert_eq!(
7848 0o600,
7849 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7850 );
7851 }
7852
7853 #[cfg(unix)]
7854 #[test]
7855 fn a_managed_commit_skips_signing_and_hooks() {
7856 use std::os::unix::fs::PermissionsExt;
7857
7858 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
7859 let common = common_git_dir(&path).unwrap();
7860 let hook = common.join("hooks/pre-commit");
7861 let marker = fixture.root.join("hook-ran");
7862 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
7863 std::fs::write(
7864 &hook,
7865 format!(
7866 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
7867 sh_quote(marker.to_str().unwrap())
7868 ),
7869 )
7870 .unwrap();
7871 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
7872 permissions.set_mode(0o755);
7873 std::fs::set_permissions(&hook, permissions).unwrap();
7874 test_git(&path, &["config", "commit.gpgsign", "true"]);
7875 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
7876 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
7877 test_git(&path, &["add", "managed.txt"]);
7878
7879 repo.commit_staged_changes(&path, "record managed change")
7880 .unwrap();
7881
7882 assert!(!marker.exists());
7883 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
7884 }
7885
7886 #[test]
7887 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
7888 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
7889 std::fs::write(
7890 path.join(".gitattributes"),
7891 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
7892 )
7893 .unwrap();
7894 test_git(&path, &["add", ".gitattributes"]);
7895 test_git(&path, &["commit", "-m", "select automatic text"]);
7896 test_git(&path, &["config", "core.autocrlf", "true"]);
7897
7898 assert!(repository_has_recoverable_work(&path, true).unwrap());
7899 }
7900
7901 #[test]
7902 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
7903 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
7904 std::fs::write(
7905 path.join(".gitattributes"),
7906 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
7907 )
7908 .unwrap();
7909 test_git(&path, &["add", ".gitattributes"]);
7910 test_git(&path, &["commit", "-m", "select ident expansion"]);
7911 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7912
7913 assert!(repository_has_recoverable_work(&path, true).unwrap());
7914 }
7915
7916 #[test]
7922 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
7923 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
7924 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
7925 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7926 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
7927 std::fs::write(
7928 path.join("bindings/uniffi-bindgen/Cargo.lock"),
7929 "# generated\n",
7930 )
7931 .unwrap();
7932
7933 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7934 .expect("a path the project ignores is the call's own output");
7935 }
7936
7937 #[test]
7938 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
7939 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
7940 exclude_paths(&repo, &["target/"]);
7941 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7942 std::fs::create_dir_all(path.join("target/package")).unwrap();
7943 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
7944
7945 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7946 .expect("an ignored attribute file governs only ignored paths");
7947 }
7948
7949 #[test]
7953 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
7954 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
7955 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7956 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
7957
7958 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7959 .expect("spar's own marker is not something a call did");
7960 }
7961
7962 #[test]
7963 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
7964 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
7965
7966 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
7967 .expect("a path a build removed is not an unreadable worktree");
7968
7969 assert_eq!(vanished_untracked_file(), gone);
7970 }
7971
7972 #[test]
7975 fn a_vanished_path_is_not_recorded_as_absent() {
7976 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
7977 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
7978 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7979 std::fs::remove_file(path.join("notes.txt")).unwrap();
7980
7981 let error = repo
7982 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7983 .unwrap_err();
7984
7985 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7986 }
7987
7988 fn exclude_paths(repo: &Repo, lines: &[&str]) {
7989 use std::io::Write;
7990 let path = repo.root().join(".git").join("info").join("exclude");
7991 let mut file = std::fs::OpenOptions::new()
7992 .create(true)
7993 .append(true)
7994 .open(&path)
7995 .unwrap();
7996 for line in lines {
7997 writeln!(file, "{line}").unwrap();
7998 }
7999 }
8000
8001 #[test]
8002 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
8003 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
8004 exclude_paths(&repo, &["dist/"]);
8005 std::fs::create_dir_all(path.join("dist")).unwrap();
8006 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8007 let baseline = repo.worktree_baseline(&path).unwrap();
8008 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8009 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8010
8011 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8012 repo.refuse_changed_existing_untracked(&path, &baseline)
8013 .unwrap();
8014 }
8015
8016 #[test]
8017 fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
8018 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
8019 exclude_paths(&repo, &["dist/", ".env.local"]);
8020 std::fs::create_dir_all(path.join("dist")).unwrap();
8021 let baseline = repo.worktree_baseline(&path).unwrap();
8022 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
8023 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
8024
8025 let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
8026
8027 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8028 assert!(error.to_string().contains(".env.local"), "{error}");
8029 }
8030
8031 #[test]
8032 fn a_read_only_inspection_may_rebuild_generated_output() {
8033 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
8034 exclude_paths(&repo, &["dist/"]);
8035 std::fs::create_dir_all(path.join("dist")).unwrap();
8036 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8037 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8038 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8039 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8040
8041 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8042 .unwrap();
8043 }
8044
8045 #[test]
8046 fn a_read_only_inspection_may_not_change_an_ignored_file_elsewhere() {
8047 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
8048 exclude_paths(&repo, &["dist/", ".env.local"]);
8049 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8050 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8051 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
8052
8053 let error = repo
8054 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8055 .unwrap_err();
8056
8057 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8058 }
8059
8060 #[test]
8061 fn build_output_alone_does_not_keep_a_worktree() {
8062 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
8063 exclude_paths(&repo, &["target/", "dist/"]);
8064 std::fs::create_dir_all(path.join("target/debug")).unwrap();
8065 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8066 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
8067 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
8068
8069 assert!(!repository_has_recoverable_work(&path, true).unwrap());
8070 repo.release_review_worktree(933);
8071
8072 assert!(!path.exists());
8073 }
8074
8075 #[test]
8076 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
8077 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
8078 exclude_paths(&repo, &["target/", ".env.local"]);
8079 std::fs::create_dir_all(path.join("target/debug")).unwrap();
8080 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8081 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
8082
8083 assert!(repository_has_recoverable_work(&path, true).unwrap());
8084 repo.release_review_worktree(934);
8085
8086 assert_eq!(
8087 "TOKEN=keep me\n",
8088 std::fs::read_to_string(path.join(".env.local")).unwrap()
8089 );
8090 }
8091
8092 #[test]
8093 fn a_repository_nested_in_build_output_keeps_a_worktree() {
8094 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
8095 exclude_paths(&repo, &["node_modules/"]);
8096 let nested = path.join("node_modules/local-dep");
8097 std::fs::create_dir_all(&nested).unwrap();
8098 test_git(&nested, &["init"]);
8099 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
8100
8101 assert!(repository_has_recoverable_work(&path, true).unwrap());
8102 repo.release_review_worktree(935);
8103
8104 assert!(nested.join(".git").exists());
8105 }
8106
8107 #[test]
8108 fn an_ordinary_untracked_file_keeps_a_worktree() {
8109 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
8110 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
8111
8112 assert!(repository_has_recoverable_work(&path, true).unwrap());
8113 repo.release_review_worktree(936);
8114
8115 assert_eq!(
8116 "somebody's notes\n",
8117 std::fs::read_to_string(path.join("notes.md")).unwrap()
8118 );
8119 }
8120
8121 #[test]
8122 fn a_legacy_crlf_checkout_is_retained_conservatively() {
8123 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
8124 std::fs::write(
8125 path.join(".gitattributes"),
8126 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
8127 )
8128 .unwrap();
8129 test_git(&path, &["add", ".gitattributes"]);
8130 test_git(&path, &["commit", "-m", "select legacy line endings"]);
8131
8132 assert!(repository_has_recoverable_work(&path, true).unwrap());
8133 }
8134
8135 #[test]
8136 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
8137 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
8138 let nested = path.join("tracked");
8139 std::fs::create_dir_all(&nested).unwrap();
8140 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
8141 test_git(&path, &["add", "tracked/seed.txt"]);
8142 test_git(&path, &["commit", "-m", "add tracked directory"]);
8143 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8144 test_git(&nested, &["init"]);
8145
8146 let error = repo
8147 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8148 .unwrap_err();
8149
8150 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8151 assert!(error.to_string().contains("Git entry"), "{error}");
8152 assert!(nested.join(".git").exists());
8153 }
8154
8155 #[test]
8160 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
8161 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
8162 exclude_paths(&repo, &["target/"]);
8163 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8164 let repro = path.join("target/pr918-repro");
8165 std::fs::create_dir_all(&repro).unwrap();
8166 test_git(&repro, &["init"]);
8167 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
8168
8169 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8170 .unwrap();
8171
8172 assert!(repository_has_recoverable_work(&path, true).unwrap());
8173 assert!(repro.join(".git").exists());
8174 }
8175
8176 #[test]
8180 fn a_repository_inside_a_nested_checkout_is_left_alone() {
8181 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
8182 exclude_paths(&repo, &["target/"]);
8183 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8184 let repro = path.join("target/repro");
8185 let vendored = repro.join("vendor/dep");
8186 std::fs::create_dir_all(&vendored).unwrap();
8187 test_git(&repro, &["init"]);
8188 test_git(&vendored, &["init"]);
8189
8190 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8191 .unwrap();
8192
8193 assert!(vendored.join(".git").exists());
8194 }
8195
8196 #[test]
8197 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
8198 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
8199
8200 let state = ignored_untracked_state(repo.root()).unwrap();
8201
8202 let relative = path.strip_prefix(repo.root()).unwrap();
8203 assert!(
8204 state.files.contains_key(relative),
8205 "{:?}",
8206 state.files.keys().collect::<Vec<_>>()
8207 );
8208 assert!(state.is_ignored(relative));
8209 }
8210
8211 #[test]
8212 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
8213 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
8214 let baseline = repo.worktree_baseline(repo.root()).unwrap();
8215 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
8216 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
8217
8218 repo.refuse_new_ignored_files(repo.root(), &baseline)
8219 .unwrap();
8220 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
8221 .unwrap();
8222 }
8223
8224 #[test]
8225 fn deleting_a_resident_worktree_during_a_call_is_refused() {
8226 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
8227 let baseline = repo.worktree_baseline(repo.root()).unwrap();
8228 std::fs::remove_dir_all(&path).unwrap();
8229
8230 let error = repo
8231 .refuse_new_ignored_files(repo.root(), &baseline)
8232 .unwrap_err();
8233
8234 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8235 assert!(error.to_string().contains("review-932"), "{error}");
8236 }
8237
8238 #[test]
8239 fn a_nested_repository_record_is_read_as_a_plain_path() {
8240 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
8241 assert_eq!(Path::new("vendor/checkout"), path);
8242 assert!(nested);
8243
8244 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
8245 assert_eq!(Path::new("vendor/notes.txt"), path);
8246 assert!(!nested);
8247
8248 assert!(untracked_record(b"/", "untracked").is_err());
8249 }
8250
8251 #[cfg(unix)]
8252 #[test]
8253 fn a_non_utf8_git_path_is_preserved_without_loss() {
8254 use std::os::unix::ffi::OsStrExt;
8255
8256 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
8257
8258 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
8259 }
8260
8261 #[test]
8262 fn guarded_merge_pins_the_reviewed_head() {
8263 let args = merge_pr_args("36", Some("abc123"), true);
8264 assert_eq!(
8265 vec![
8266 "pr",
8267 "merge",
8268 "36",
8269 "--squash",
8270 "--delete-branch",
8271 "--match-head-commit",
8272 "abc123"
8273 ],
8274 args
8275 );
8276 }
8277
8278 #[test]
8279 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
8280 let pr = PrRef {
8281 number: 7,
8282 url: "https://example.test/pull/7".into(),
8283 title: "part one".into(),
8284 };
8285 let result = reconcile_pr_creation(
8286 "split-34-1",
8287 Err(crate::error::SparError::new("connection lost")),
8288 Ok(Some(pr)),
8289 )
8290 .unwrap();
8291 assert_eq!(7, result.number);
8292 }
8293
8294 #[test]
8295 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
8296 let error = reconcile_pr_creation(
8297 "split-34-1",
8298 Err(crate::error::SparError::new("permission denied")),
8299 Ok(None),
8300 )
8301 .unwrap_err();
8302 assert!(error.to_string().contains("permission denied"), "{error}");
8303 }
8304
8305 #[test]
8306 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
8307 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
8308 assert!(pr_for_base(text, "split-34-2", "split-34-1")
8309 .unwrap()
8310 .is_none());
8311 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
8312 assert_eq!(7, found.number);
8313 }
8314
8315 #[test]
8316 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
8317 let result = reconcile_comment_post(
8318 34,
8319 "the summary",
8320 crate::error::SparError::new("connection lost"),
8321 Ok(vec![serde_json::json!({"body": "the summary"})]),
8322 );
8323 assert!(result.is_ok(), "{result:?}");
8324 }
8325
8326 #[test]
8327 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
8328 let error = reconcile_comment_post(
8329 34,
8330 "the summary",
8331 crate::error::SparError::new("connection lost"),
8332 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
8333 )
8334 .unwrap_err();
8335 assert_eq!("connection lost", error.to_string());
8336 }
8337
8338 #[test]
8339 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
8340 let error = reconcile_comment_post(
8341 34,
8342 "the summary",
8343 crate::error::SparError::new("connection lost"),
8344 Err(crate::error::SparError::new("comments unavailable")),
8345 )
8346 .unwrap_err();
8347 assert!(
8348 error.to_string().contains("could not be verified"),
8349 "{error}"
8350 );
8351 assert!(
8352 error.to_string().contains("comments unavailable"),
8353 "{error}"
8354 );
8355 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8356 assert!(!error.worth_retrying());
8357 }
8358
8359 #[test]
8360 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
8361 let result = reconcile_issue_edit(
8362 34,
8363 "wanted body",
8364 crate::error::SparError::new("connection lost"),
8365 Ok("wanted body".to_string()),
8366 );
8367 assert!(result.is_ok(), "{result:?}");
8368 }
8369
8370 #[test]
8371 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
8372 let error = reconcile_issue_edit(
8373 34,
8374 "wanted body",
8375 crate::error::SparError::new("connection lost"),
8376 Err(crate::error::SparError::new("issue unavailable")),
8377 )
8378 .unwrap_err();
8379 assert!(
8380 error.to_string().contains("could not be verified"),
8381 "{error}"
8382 );
8383 assert!(error.to_string().contains("issue unavailable"), "{error}");
8384 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8385 assert!(!error.worth_retrying());
8386 }
8387
8388 #[test]
8389 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
8390 let found = ExistingIssue {
8391 number: 101,
8392 url: "https://example.test/issues/101".into(),
8393 title: "child".into(),
8394 body: "body".into(),
8395 open: true,
8396 };
8397 let url = reconcile_issue_creation(
8398 "child",
8399 Err(crate::error::SparError::new("connection lost")),
8400 Ok(Some(found)),
8401 )
8402 .unwrap();
8403 assert_eq!("https://example.test/issues/101", url);
8404 }
8405
8406 #[test]
8407 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8408 let error = reconcile_issue_creation(
8409 "child",
8410 Err(crate::error::SparError::new("permission denied")),
8411 Ok(None),
8412 )
8413 .unwrap_err();
8414 assert!(error.to_string().contains("permission denied"), "{error}");
8415 }
8416
8417 #[test]
8418 fn an_unverifiable_issue_creation_is_marked_uncertain() {
8419 let error = reconcile_issue_creation(
8420 "child",
8421 Err(crate::error::SparError::new("connection lost")),
8422 Err(crate::error::SparError::new("issues unavailable")),
8423 )
8424 .unwrap_err();
8425 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8426 assert!(!error.worth_retrying());
8427 }
8428
8429 #[test]
8430 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8431 let result = reconcile_failed_split_push(
8432 "split-34-1",
8433 crate::error::SparError::new("connection lost"),
8434 Ok("abc123\n".into()),
8435 Ok("abc123\trefs/heads/split-34-1\n".into()),
8436 );
8437 assert!(result.is_ok(), "{result:?}");
8438 }
8439
8440 #[test]
8441 fn a_split_push_collision_is_definite_and_never_overwrites() {
8442 let error = reconcile_failed_split_push(
8443 "split-34-1",
8444 crate::error::SparError::new("lease rejected"),
8445 Ok("abc123\n".into()),
8446 Ok("def456\trefs/heads/split-34-1\n".into()),
8447 )
8448 .unwrap_err();
8449 assert!(!error.retain_worktree());
8450 assert!(
8451 error.to_string().contains("Nothing was overwritten"),
8452 "{error}"
8453 );
8454 }
8455
8456 #[test]
8457 fn an_unreadable_split_push_result_keeps_the_worktree() {
8458 let error = reconcile_failed_split_push(
8459 "split-34-1",
8460 crate::error::SparError::new("connection lost"),
8461 Ok("abc123\n".into()),
8462 Err(crate::error::SparError::new("origin unavailable")),
8463 )
8464 .unwrap_err();
8465 assert!(error.retain_worktree());
8466 assert!(error.to_string().contains("could not confirm"), "{error}");
8467 }
8468
8469 #[test]
8473 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8474 let repo = repo_for_titles();
8475 for raw in [
8476 "Retry loop spins \u{2014} Retry-After parses to zero",
8477 "plain title",
8478 " spread over\nlines ",
8479 "\u{1F916} Generated with something",
8480 &format!("a \u{2014} {}", "very long title ".repeat(20)),
8481 &"x".repeat(300),
8482 &format!("{} \u{2014} end", "y".repeat(88)),
8483 &{
8488 let tail = "a\u{2014}b c\u{2014}d";
8489 let pad = Style::default().max_title_chars - tail.chars().count();
8490 format!("{}{tail}", "w".repeat(pad))
8491 },
8492 ] {
8493 let once = repo.clean_title(raw).unwrap();
8494 let twice = repo.clean_title(&once).unwrap();
8495 assert_eq!(once, twice, "not idempotent for {raw:?}");
8496 assert!(
8497 once.chars().count() <= repo.style.max_title_chars,
8498 "over budget: {once:?}"
8499 );
8500 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8501 }
8502 }
8503
8504 #[test]
8505 fn a_title_with_an_em_dash_survives_as_readable_text() {
8506 let repo = repo_for_titles();
8507 assert_eq!(
8508 "Retry loop spins, Retry-After parses to zero",
8509 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8510 .unwrap()
8511 );
8512 }
8513
8514 #[test]
8515 fn sh_quote_survives_a_quote() {
8516 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
8517 }
8518
8519 #[test]
8520 fn sh_quote_wraps_a_space() {
8521 assert_eq!(
8522 "'/Applications/My App/spar'",
8523 sh_quote("/Applications/My App/spar")
8524 );
8525 }
8526
8527 #[test]
8528 fn finished_states_are_recognised_case_insensitively() {
8529 assert!(is_finished("MERGED"));
8530 assert!(is_finished("closed"));
8531 assert!(!is_finished("OPEN"));
8532 assert!(!is_finished(""));
8533 }
8534
8535 fn state() -> PersistedState {
8536 PersistedState {
8537 version: 1,
8538 checkpoint: 0,
8539 round: 4,
8540 next_actor: "codex".into(),
8541 status: Status::Pending,
8542 pr_head: "abc123".into(),
8543 ledger: Ledger::new(),
8544 filed: vec![],
8545 open_findings: vec![Finding {
8546 severity: Severity::Blocking,
8547 title: "Unchecked error".into(),
8548 detail: "the failure is discarded".into(),
8549 file: "src/a.rs:12".into(),
8550 ..Finding::default()
8551 }],
8552 disputes: vec![Dispute {
8553 title: "Retry limit".into(),
8554 file: "src/net.rs".into(),
8555 reasoning: "the caller already bounds it".into(),
8556 }],
8557 noted: vec![Finding {
8558 severity: Severity::NonBlocking,
8559 title: "Timeout is fixed".into(),
8560 file: "src/config.rs".into(),
8561 ..Finding::default()
8562 }],
8563 }
8564 }
8565
8566 #[test]
8567 fn a_state_comment_round_trips() {
8568 let body = format!(
8569 "{STATE_MARKER}\n{}\n-->",
8570 serde_json::to_string(&state()).unwrap()
8571 );
8572 let back = parse_state_comment(&body).unwrap();
8573 assert_eq!(4, back.round);
8574 assert_eq!("codex", back.next_actor);
8575 assert_eq!("abc123", back.pr_head);
8576 assert_eq!("Unchecked error", back.open_findings[0].title);
8577 assert_eq!("src/net.rs", back.disputes[0].file);
8578 assert_eq!("Timeout is fixed", back.noted[0].title);
8579 }
8580
8581 #[test]
8582 fn old_state_without_new_lists_still_parses() {
8583 let body = format!(
8584 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
8585 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8586 );
8587 let back = parse_state_comment(&body).expect("old state");
8588 assert!(back.open_findings.is_empty());
8589 assert!(back.disputes.is_empty());
8590 assert!(back.noted.is_empty());
8591 assert!(back.pr_head.is_empty());
8592 assert_eq!(0, back.checkpoint);
8593 }
8594
8595 #[test]
8596 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
8597 let mut local = state();
8598 local.pr_head = "old".into();
8599 local.round = 9;
8600 let mut remote = state();
8601 remote.pr_head = "current".into();
8602 remote.round = 4;
8603
8604 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8605 assert_eq!("current", chosen.pr_head);
8606 assert_eq!(4, chosen.round);
8607 }
8608
8609 #[test]
8610 fn checkpoint_order_breaks_same_round_ties() {
8611 let mut local = state();
8612 local.pr_head = "current".into();
8613 local.round = 4;
8614 local.checkpoint = 8;
8615 let mut remote = local.clone();
8616 remote.checkpoint = 7;
8617 remote.open_findings.clear();
8618
8619 let chosen = choose_state_for_head(vec![local], "current").unwrap();
8620 assert_eq!(8, chosen.checkpoint);
8621
8622 let mut local = state();
8623 local.pr_head = "current".into();
8624 local.round = 4;
8625 local.checkpoint = 8;
8626 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
8627 assert_eq!(8, chosen.checkpoint);
8628 }
8629
8630 #[test]
8631 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
8632 let mut local = state();
8633 local.pr_head = "current".into();
8634 local.round = 4;
8635 local.open_findings.push(Finding {
8636 title: "local checkpoint".into(),
8637 ..Finding::default()
8638 });
8639 let mut remote = state();
8640 remote.pr_head = "current".into();
8641 remote.round = 4;
8642
8643 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8644 assert_eq!(
8645 "local checkpoint",
8646 chosen.open_findings.last().unwrap().title
8647 );
8648 }
8649
8650 #[test]
8652 fn the_state_block_is_an_html_comment() {
8653 let body = format!(
8654 "{STATE_MARKER}\n{}\n-->",
8655 serde_json::to_string(&state()).unwrap()
8656 );
8657 assert!(body.starts_with("<!--"));
8658 assert!(body.trim_end().ends_with("-->"));
8659 assert!(!body[..body.find('{').unwrap()].contains("-->"));
8660 }
8661
8662 #[test]
8663 fn an_unrelated_json_block_is_not_state() {
8664 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
8665 }
8666
8667 #[test]
8674 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
8675 let block = format!(
8676 "{STATE_MARKER}\n{}\n-->",
8677 serde_json::to_string(&state()).unwrap()
8678 );
8679 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
8680 assert!(
8681 is_state_comment(&block, "Spar-Bot", "spar-bot"),
8682 "a login differs only in case"
8683 );
8684
8685 assert!(
8686 !is_state_comment(
8687 "why is there a `<!-- spar:state` block on this PR?",
8688 "maintainer",
8689 "spar-bot"
8690 ),
8691 "a question about the block was treated as the block"
8692 );
8693 assert!(
8694 !is_state_comment(&block, "maintainer", "spar-bot"),
8695 "somebody else's comment is not spar's to rewrite"
8696 );
8697 assert!(
8698 !is_state_comment(
8699 &format!("here is what spar writes:\n\n{block}"),
8700 "spar-bot",
8701 "spar-bot"
8702 ),
8703 "the marker has to open the body, not appear in it"
8704 );
8705 assert!(
8706 !is_state_comment(
8707 &format!("{STATE_MARKER}\n{{not json\n-->"),
8708 "spar-bot",
8709 "spar-bot"
8710 ),
8711 "a block that does not parse is not state"
8712 );
8713 assert!(
8714 !is_state_comment(&block, "spar-bot", ""),
8715 "an unknown viewer proves nothing about who wrote it"
8716 );
8717 }
8718
8719 #[test]
8720 fn a_malformed_state_comment_is_none_not_a_panic() {
8721 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
8722 }
8723
8724 #[test]
8725 fn atomic_write_leaves_no_temp_file() {
8726 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
8727 let _ = std::fs::remove_dir_all(&dir);
8728 let path = dir.join("state").join("pr-7.json");
8729 write_json_atomic(&path, &state()).unwrap();
8730 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
8731 .unwrap()
8732 .flatten()
8733 .filter_map(|e| e.file_name().to_str().map(str::to_string))
8734 .collect();
8735 assert_eq!(vec!["pr-7.json".to_string()], files);
8736 let _ = std::fs::remove_dir_all(&dir);
8737 }
8738
8739 #[test]
8740 fn atomic_write_overwrites_rather_than_accumulating() {
8741 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
8742 let _ = std::fs::remove_dir_all(&dir);
8743 let path = dir.join("pr-7.json");
8744 for round in 1..4 {
8745 let mut s = state();
8746 s.round = round;
8747 write_json_atomic(&path, &s).unwrap();
8748 }
8749 let back: PersistedState =
8750 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
8751 assert_eq!(3, back.round);
8752 let _ = std::fs::remove_dir_all(&dir);
8753 }
8754
8755 #[test]
8756 fn style_from_env_defaults_to_enforcing() {
8757 std::env::remove_var("SPAR_BAN_EM_DASH");
8758 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
8759 let style = style_from_env();
8760 assert!(style.ban_em_dash && style.ban_ai_attribution);
8761 assert!(
8762 !style.terse,
8763 "the commit filter must not truncate a commit message"
8764 );
8765 }
8766}
8767
8768#[cfg(test)]
8769mod comment_page_tests {
8770 use super::*;
8771
8772 #[test]
8773 fn a_single_merged_array_is_read() {
8774 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
8775 assert_eq!(2, pages.len());
8776 assert_eq!(Some(2), pages[1]["id"].as_i64());
8777 }
8778
8779 #[test]
8780 fn concatenated_pages_from_an_older_gh_are_read_too() {
8781 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
8782 assert_eq!(2, pages.len());
8783 }
8784
8785 #[test]
8789 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
8790 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
8791 let pages = parse_comment_pages(text);
8792 assert_eq!(2, pages.len(), "{pages:?}");
8793 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
8794 }
8795
8796 #[test]
8797 fn empty_output_is_no_comments_not_a_panic() {
8798 assert!(parse_comment_pages("").is_empty());
8799 assert!(parse_comment_pages(" ").is_empty());
8800 assert!(parse_comment_pages("[]").is_empty());
8801 }
8802
8803 #[test]
8804 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
8805 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
8806 }
8807
8808 #[test]
8809 fn a_write_postcheck_rejects_truncated_comment_pages() {
8810 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
8811 assert!(
8812 error.to_string().contains("unexpected comment pages"),
8813 "{error}"
8814 );
8815 }
8816
8817 #[test]
8818 fn a_write_postcheck_rejects_empty_or_non_array_output() {
8819 assert!(try_parse_comment_pages("").is_err());
8820 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
8821 assert!(try_parse_comment_pages("[]").is_ok());
8822 }
8823
8824 #[test]
8825 fn state_is_found_in_the_last_matching_comment() {
8826 let payload = |round: u32| {
8827 format!(
8828 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8829 )
8830 };
8831 let text = serde_json::to_string(&serde_json::json!([
8832 {"id": 1, "body": payload(1)},
8833 {"id": 2, "body": "looks good to me"},
8834 {"id": 3, "body": payload(5)},
8835 ]))
8836 .unwrap();
8837 let pages = parse_comment_pages(&text);
8838 let last = pages
8839 .iter()
8840 .rev()
8841 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
8842 .unwrap();
8843 assert_eq!(5, last.round);
8844 }
8845}
8846
8847#[cfg(test)]
8848mod linked_pr_tests {
8849 use super::*;
8850
8851 const REAL_PAYLOAD: &str = r#"[
8856 {"number":14252,"title":"fix: reject leading-dash branch names",
8857 "url":"https://github.com/cli/cli/pull/14252",
8858 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
8859 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8860 "url":"https://github.com/cli/cli/issues/14238"}]},
8861 {"number":14217,"title":"another change",
8862 "url":"https://github.com/cli/cli/pull/14217",
8863 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
8864 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8865 "url":"https://github.com/cli/cli/issues/9761"}]},
8866 {"number":14200,"title":"unlinked work",
8867 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
8868 ]"#;
8869
8870 #[test]
8871 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
8872 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
8873 assert_eq!(14252, pr.number);
8874 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
8875 }
8876
8877 #[test]
8878 fn the_right_pr_is_picked_out_of_several() {
8879 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
8880 }
8881
8882 #[test]
8883 fn an_issue_nobody_is_working_on_finds_nothing() {
8884 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
8885 }
8886
8887 #[test]
8888 fn an_unlinked_pr_is_never_matched() {
8889 for issue in [14200, 0, 1] {
8891 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
8892 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
8893 }
8894 }
8895 }
8896
8897 #[test]
8898 fn empty_or_broken_output_is_none_rather_than_a_panic() {
8899 assert!(find_linked_pr("", 1).is_none());
8900 assert!(find_linked_pr("[]", 1).is_none());
8901 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
8902 assert!(find_linked_pr("[{\"number\":", 1).is_none());
8903 }
8904
8905 #[test]
8912 fn an_unreadable_list_is_not_an_empty_list() {
8913 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
8914 assert!(linked_pr_rows("[{\"number\":").is_none());
8915 assert!(linked_pr_rows("").is_none());
8916
8917 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
8918 assert!(empty.is_empty());
8919 assert!(pick_linked_pr(&empty, 14238).is_none());
8920
8921 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
8922 assert_eq!(3, listed.len());
8923 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
8924 }
8925
8926 #[test]
8928 fn pr_view_reads_the_cross_repository_flag() {
8929 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
8930 "baseRefName":"main","state":"OPEN",
8931 "closingIssuesReferences":[],"isCrossRepository":true}"#;
8932 let pr: PrView = serde_json::from_str(json).unwrap();
8933 assert!(pr.is_cross_repository);
8934 assert!(pr.is_open());
8935
8936 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
8937 assert!(
8938 !serde_json::from_str::<PrView>(&same_repo)
8939 .unwrap()
8940 .is_cross_repository
8941 );
8942 }
8943}
8944
8945#[cfg(test)]
8946mod min_number_tests {
8947 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
8953 let mut numbers: Vec<i64> = open.to_vec();
8954 numbers.sort_unstable();
8955 if min_number > 0 {
8956 numbers.retain(|n| *n >= min_number);
8957 }
8958 numbers.truncate(limit);
8959 numbers
8960 }
8961
8962 #[test]
8963 fn the_floor_is_applied_before_the_cap_not_after() {
8964 let open = [12, 13, 14, 480, 481, 482];
8965 assert_eq!(vec![480, 481], pick(&open, 2, 480));
8966 assert!(!pick(&open, 2, 480).is_empty());
8969 }
8970
8971 #[test]
8972 fn no_floor_keeps_the_old_behaviour() {
8973 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
8974 }
8975
8976 #[test]
8977 fn the_floor_is_inclusive() {
8978 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
8979 }
8980
8981 #[test]
8982 fn a_floor_above_everything_open_yields_nothing() {
8983 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
8984 }
8985}