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