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