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 pub(crate) fn refuse_read_only_leavings(
2584 &self,
2585 cwd: &Path,
2586 baseline: &WorktreeBaseline,
2587 ) -> Result<()> {
2588 self.refuse_changed_attributes(cwd, baseline)?;
2589 let after = ignored_untracked_state(cwd).map_err(|e| {
2590 uncertain_worktree_change(
2591 cwd,
2592 format!(
2593 "could not verify untracked files in {} after a read-only call: {}. The \
2594 worktree was kept for recovery.",
2595 cwd.display(),
2596 e.last_line()
2597 ),
2598 )
2599 })?;
2600 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
2601 if leavings.condemning.is_empty() {
2602 return Ok(());
2603 }
2604 Err(uncertain_worktree_change(
2605 cwd,
2606 format!(
2607 "the read-only call left untracked file(s) in {} that the project does not \
2608 ignore: {}. The worktree was kept for recovery.",
2609 cwd.display(),
2610 list_paths(&leavings.condemning)
2611 ),
2612 ))
2613 }
2614
2615 fn allow_generated_ignored_files(
2618 &self,
2619 cwd: &Path,
2620 baseline: &WorktreeBaseline,
2621 ) -> Result<AllowedArtifacts> {
2622 self.check_new_ignored_files(cwd, baseline)
2623 }
2624
2625 fn check_new_ignored_files(
2626 &self,
2627 cwd: &Path,
2628 baseline: &WorktreeBaseline,
2629 ) -> Result<AllowedArtifacts> {
2630 self.refuse_changed_attributes(cwd, baseline)?;
2631 let after = ignored_untracked_state(cwd).map_err(|e| {
2632 uncertain_worktree_change(
2633 cwd,
2634 format!(
2635 "could not verify untracked files in {} after editing: {}. The worktree was \
2636 kept for recovery.",
2637 cwd.display(),
2638 e.last_line()
2639 ),
2640 )
2641 })?;
2642 let changed = baseline.ignored_untracked.changed_paths(&after);
2643 if changed.is_empty() {
2644 return Ok(AllowedArtifacts::default());
2645 }
2646 let (generated, changed): (Vec<_>, Vec<_>) = changed
2647 .into_iter()
2648 .partition(|path| baseline.ignored_untracked.generated_move(&after, path));
2649 if changed.is_empty() {
2650 return Ok(AllowedArtifacts::split(generated, &after));
2651 }
2652 let listed = list_paths(&changed);
2653 Err(uncertain_worktree_change(
2654 cwd,
2655 format!(
2656 "the editing call created or changed untracked or ignored file(s) in {} that \
2657 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2658 recovery.",
2659 cwd.display()
2660 ),
2661 ))
2662 }
2663
2664 pub(crate) fn refuse_changed_existing_untracked(
2670 &self,
2671 cwd: &Path,
2672 baseline: &WorktreeBaseline,
2673 ) -> Result<()> {
2674 self.check_changed_existing_untracked(cwd, baseline)
2675 .map(drop)
2676 }
2677
2678 fn allow_changed_generated_artifacts(
2679 &self,
2680 cwd: &Path,
2681 baseline: &WorktreeBaseline,
2682 ) -> Result<AllowedArtifacts> {
2683 self.check_changed_existing_untracked(cwd, baseline)
2684 }
2685
2686 fn check_changed_existing_untracked(
2687 &self,
2688 cwd: &Path,
2689 baseline: &WorktreeBaseline,
2690 ) -> Result<AllowedArtifacts> {
2691 self.refuse_changed_attributes(cwd, baseline)?;
2692 let after = ignored_untracked_state(cwd).map_err(|e| {
2693 uncertain_worktree_change(
2694 cwd,
2695 format!(
2696 "could not verify existing untracked files in {} after editing: {}. The \
2697 worktree was kept for recovery.",
2698 cwd.display(),
2699 e.last_line()
2700 ),
2701 )
2702 })?;
2703 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2704 if changed.is_empty() {
2705 return Ok(AllowedArtifacts::default());
2706 }
2707 let (generated, changed): (Vec<_>, Vec<_>) = changed
2708 .into_iter()
2709 .partition(|path| baseline.ignored_untracked.generated_move(&after, path));
2710 if changed.is_empty() {
2711 return Ok(AllowedArtifacts::split(generated, &after));
2712 }
2713 let listed = list_paths(&changed);
2714 Err(uncertain_worktree_change(
2715 cwd,
2716 format!(
2717 "the editing call changed or deleted existing untracked file(s) in {}: \
2718 {listed}. The worktree was kept for recovery.",
2719 cwd.display()
2720 ),
2721 ))
2722 }
2723
2724 pub(crate) fn refuse_unrepresented_tracked_changes(
2731 &self,
2732 cwd: &Path,
2733 baseline: &WorktreeBaseline,
2734 ) -> Result<()> {
2735 self.refuse_changed_attributes(cwd, baseline)?;
2736 let after = safe_git_state(cwd).map_err(|e| {
2737 uncertain_worktree_change(
2738 cwd,
2739 format!(
2740 "could not verify tracked files in {} after editing: {}. The worktree was \
2741 kept for recovery.",
2742 cwd.display(),
2743 e.last_line()
2744 ),
2745 )
2746 })?;
2747 let mut changed = Vec::new();
2748 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
2749 uncertain_worktree_change(
2750 cwd,
2751 format!(
2752 "could not record untracked files before verifying transformed content in {}: \
2753 {}. The worktree was kept for recovery.",
2754 cwd.display(),
2755 e.last_line()
2756 ),
2757 )
2758 })?;
2759 let mut filter_was_run = false;
2760 let mut filter_problem = None;
2761 let mut repositories: BTreeSet<PathBuf> =
2762 baseline.git_state.repositories.keys().cloned().collect();
2763 repositories.extend(after.repositories.keys().cloned());
2764 'repositories: for repository_path in repositories {
2765 let before_repository = baseline.git_state.repositories.get(&repository_path);
2766 let after_repository = after.repositories.get(&repository_path);
2767 if before_repository.is_none() || after_repository.is_none() {
2768 changed.push(repository_path.clone());
2769 continue;
2770 }
2771 if before_repository.map(|repository| &repository.gitlinks)
2772 != after_repository.map(|repository| &repository.gitlinks)
2773 {
2774 changed.push(repository_path.join("<gitlinks>"));
2775 }
2776 let mut paths = BTreeSet::new();
2777 if let Some(repository) = before_repository {
2778 paths.extend(repository.tracked.keys().cloned());
2779 }
2780 if let Some(repository) = after_repository {
2781 paths.extend(repository.tracked.keys().cloned());
2782 }
2783 for path in paths {
2784 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
2785 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
2786 let worktree_changed =
2787 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
2788 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
2789 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
2790 if !worktree_changed {
2791 continue;
2792 }
2793 if !index_changed {
2794 changed.push(repository_path.join(&path));
2795 continue;
2796 }
2797 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
2798 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
2799 let Some(current_entry) = current else {
2800 continue;
2801 };
2802 let Some(current_worktree) = current_worktree else {
2803 continue;
2804 };
2805 let content_changed =
2806 before_worktree.map(|file| file.content) != Some(current_worktree.content);
2807 let mode_changed = before_worktree.map(|file| file.mode.as_str())
2808 != Some(current_worktree.mode.as_str());
2809 let repository = cwd.join(&repository_path);
2810 let represented_content = if content_changed {
2811 filter_was_run = true;
2812 let result =
2813 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
2814 self.refuse_changed_attributes(cwd, baseline)?;
2815 match result {
2816 Ok(expected) => expected == current_worktree.content,
2817 Err(error) => {
2818 filter_problem = Some(format!(
2819 "could not verify transformed content for {:?}: {}",
2820 repository_path.join(&path),
2821 error.last_line()
2822 ));
2823 false
2824 }
2825 }
2826 } else {
2827 true
2828 };
2829 let represented_mode =
2830 !mode_changed || current_worktree.mode == current_entry.index_mode;
2831 if !represented_content || !represented_mode {
2832 changed.push(repository_path.join(&path));
2833 }
2834 if filter_problem.is_some() {
2835 break 'repositories;
2836 }
2837 }
2838 }
2839 if filter_was_run {
2840 self.refuse_changed_attributes(cwd, baseline)?;
2841 let verified = safe_git_state(cwd).map_err(|e| {
2842 uncertain_worktree_change(
2843 cwd,
2844 format!(
2845 "could not recheck tracked files after verifying transformed content in \
2846 {}: {}. The worktree was kept for recovery.",
2847 cwd.display(),
2848 e.last_line()
2849 ),
2850 )
2851 })?;
2852 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
2853 uncertain_worktree_change(
2854 cwd,
2855 format!(
2856 "could not recheck untracked files after verifying transformed content \
2857 in {}: {}. The worktree was kept for recovery.",
2858 cwd.display(),
2859 e.last_line()
2860 ),
2861 )
2862 })?;
2863 if verified != after
2864 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
2865 {
2866 return Err(uncertain_worktree_change(
2867 cwd,
2868 "a content filter changed the worktree while SPAR verified the managed \
2869 commit. The worktree was kept for recovery.",
2870 ));
2871 }
2872 self.refuse_changed_existing_untracked(cwd, baseline)?;
2873 }
2874 if let Some(problem) = filter_problem {
2875 return Err(uncertain_worktree_change(
2876 cwd,
2877 format!("{problem}. The worktree was kept for recovery."),
2878 ));
2879 }
2880 if changed.is_empty() {
2881 return Ok(());
2882 }
2883 let listed = list_paths(&changed);
2884 Err(uncertain_worktree_change(
2885 cwd,
2886 format!(
2887 "the editing call changed tracked working-file bytes, modes, repositories, or \
2888 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
2889 recovery."
2890 ),
2891 ))
2892 }
2893
2894 pub(crate) fn refuse_changed_attributes(
2895 &self,
2896 cwd: &Path,
2897 baseline: &WorktreeBaseline,
2898 ) -> Result<()> {
2899 let after = attribute_state(cwd).map_err(|e| {
2900 uncertain_worktree_change(
2901 cwd,
2902 format!(
2903 "could not verify attribute files in {} after editing: {}. The worktree was \
2904 kept for recovery.",
2905 cwd.display(),
2906 e.last_line()
2907 ),
2908 )
2909 })?;
2910 if after == baseline.attributes {
2911 return Ok(());
2912 }
2913 Err(uncertain_worktree_change(
2914 cwd,
2915 format!(
2916 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
2917 refused to run a Git operation that could select a new external filter.",
2918 cwd.display()
2919 ),
2920 ))
2921 }
2922
2923 pub(crate) fn commit_pending_changes(
2928 &self,
2929 cwd: &Path,
2930 baseline: &WorktreeBaseline,
2931 preferred_subject: &str,
2932 fallback_subject: &str,
2933 ) -> Result<bool> {
2934 let mut artifacts = GeneratedArtifacts::default();
2935 self.refuse_changed_attributes(cwd, baseline)?;
2936 artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2937 refuse_unsafe_index_flags(cwd)?;
2938 if !self.has_uncommitted_changes(cwd)? {
2939 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2940 artifacts.report(cwd);
2941 return Ok(false);
2942 }
2943 self.stage_managed_changes(cwd, baseline).map_err(|e| {
2944 e.with_message(format!(
2945 "could not stage changes in {}: {}",
2946 cwd.display(),
2947 e.last_line()
2948 ))
2949 })?;
2950 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2953 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
2954 if !changed_gitlinks.is_empty() {
2955 let listed = list_paths(&changed_gitlinks);
2956 bail!(
2957 "the editing call added or changed a gitlink at {listed}. It was staged but not \
2958 committed because the referenced repository objects might exist only inside \
2959 this worktree. The worktree was kept for recovery."
2960 );
2961 }
2962 let mut subject = self.clean_title(preferred_subject)?;
2963 if subject.trim().is_empty() {
2964 subject = self.clean_title(fallback_subject)?;
2965 }
2966 self.commit_staged_changes(cwd, &subject).map_err(|e| {
2967 e.with_message(format!(
2968 "could not commit changes in {}: {}. The staged files were kept.",
2969 cwd.display(),
2970 e.last_line()
2971 ))
2972 })?;
2973 if has_tracked_or_staged_work(cwd)? {
2974 bail!(
2975 "the commit in {} left additional uncommitted files. They were kept for \
2976 recovery.",
2977 cwd.display()
2978 );
2979 }
2980 artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2981 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2982 artifacts.report(cwd);
2983 Ok(true)
2984 }
2985
2986 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
2987 let after = ignored_untracked_state(cwd)?;
2988 self.git_at_without_automation(cwd, &["add", "-u"])?;
2989 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
2990 if paths.is_empty() {
2991 return Ok(());
2992 }
2993 let mut input = Vec::new();
2994 for path in paths {
2995 input.extend(os_str_bytes(path.as_os_str())?);
2996 input.push(0);
2997 }
2998 let argv = git_without_automation_argv(&[
2999 "--literal-pathspecs",
3000 "add",
3001 "--pathspec-from-file=-",
3002 "--pathspec-file-nul",
3003 ]);
3004 proc::run_with_input_bytes(
3005 &argv,
3006 &self.git_opts(Some(cwd), true).stop_descendants(true),
3007 &input,
3008 )?;
3009 Ok(())
3010 }
3011
3012 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3015 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3016 .map(|_| ())
3017 }
3018
3019 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3026 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3027 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3028 .trim()
3029 .parse()
3030 .unwrap_or(0)
3031 }
3032
3033 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3037 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3038 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3039 .lines()
3040 .map(str::to_string)
3041 .collect()
3042 }
3043
3044 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3059 let ancestor = self
3060 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3061 .is_ok();
3062 if !ancestor {
3063 return None;
3064 }
3065 let range = format!("{earlier}..{later}");
3066 Some(
3067 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3068 .lines()
3069 .map(str::to_string)
3070 .collect(),
3071 )
3072 }
3073
3074 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3077 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3078 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3079 .lines()
3080 .map(str::trim)
3081 .filter(|line| !line.is_empty())
3082 .map(str::to_string)
3083 .collect()
3084 }
3085
3086 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3102 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3103 self.git_try_at(
3104 Some(cwd),
3105 &["diff", "--name-only", "--no-renames", "-z", &range],
3106 )
3107 .split('\0')
3108 .filter(|path| !path.is_empty())
3109 .map(str::to_string)
3110 .collect()
3111 }
3112
3113 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3116 self.git_try_at(
3117 Some(cwd),
3118 &[
3119 "diff",
3120 "--name-only",
3121 "--no-renames",
3122 "-z",
3123 &format!("{from}..{to}"),
3124 ],
3125 )
3126 .split('\0')
3127 .filter(|path| !path.is_empty())
3128 .map(str::to_string)
3129 .collect()
3130 }
3131
3132 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3135 let base_ref = self.base_ref(cwd, base);
3136 let out = self
3137 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3138 .map_err(|e| {
3139 spar_err!(
3140 "could not find where {refname} and {base_ref} diverged. {}",
3141 e.last_line()
3142 )
3143 })?;
3144 let sha = out.trim().to_string();
3145 if sha.is_empty() {
3146 bail!("{refname} and {base_ref} share no history");
3147 }
3148 Ok(sha)
3149 }
3150
3151 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3152 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3153 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3154 full.trim().to_string()
3155 }
3156
3157 pub fn rewrite_commits_if_needed(
3170 &self,
3171 cwd: &Path,
3172 base: &str,
3173 floor: Option<&str>,
3174 ) -> Result<()> {
3175 let base_ref = self.base_ref(cwd, base);
3176 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3177 self.report_commits_left_alone(cwd, &base_ref, floor);
3178
3179 let range = format!("{floor}..HEAD");
3180 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3181
3182 let offenders = raw
3183 .split('\x1e')
3184 .filter_map(|entry| entry.split_once('\0'))
3185 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3186 .count();
3187 if offenders == 0 {
3188 return Ok(());
3189 }
3190 logdim!("{offenders} commit message(s) violated style rules, rewriting");
3191
3192 let exe = self_binary()?;
3193 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3194
3195 let argv: Vec<String> = [
3196 "git",
3197 "filter-branch",
3198 "-f",
3199 "--msg-filter",
3200 &filter,
3201 &range,
3202 ]
3203 .iter()
3204 .map(|s| s.to_string())
3205 .collect();
3206 let opts = ExecOpts::new()
3207 .cwd(cwd)
3208 .check(false)
3209 .timeout_secs(600)
3210 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3211 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3212 .env(
3213 "SPAR_BAN_AI_ATTRIBUTION",
3214 bool_env(self.style.ban_ai_attribution),
3215 );
3216 let _ = proc::run(&argv, &opts);
3217
3218 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3219 if !style::violations(&after, &self.style).is_empty() {
3220 bail!(
3221 "commit messages still violate style rules after a rewrite in {}.",
3222 cwd.display()
3223 );
3224 }
3225 Ok(())
3226 }
3227
3228 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3233 if floor == base_ref {
3234 return;
3235 }
3236 let raw = self.git_try_at(
3237 Some(cwd),
3238 &[
3239 "log",
3240 &format!("{base_ref}..{floor}"),
3241 "--format=%H%x00%B%x1e",
3242 ],
3243 );
3244 let left: Vec<&str> = raw
3245 .split('\x1e')
3246 .filter_map(|entry| entry.split_once('\0'))
3247 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3248 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3249 .collect();
3250 if left.is_empty() {
3251 return;
3252 }
3253 logdim!(
3254 "{} commit message(s) already on the branch break the style rules and were left \
3255 alone: {}. spar does not rewrite commits it did not write.",
3256 left.len(),
3257 left.join(", ")
3258 );
3259 }
3260
3261 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3276 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3277 if let Some(head) = expected {
3278 self.refuse_push_that_drops(cwd, head)?;
3279 }
3280 let refspec = format!("HEAD:{branch}");
3281 let lease = match expected {
3282 Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
3283 None => "--force-with-lease".to_string(),
3284 };
3285 let pushed = self
3286 .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
3287 .map(|_| ())
3288 .map_err(|e| {
3289 spar_err!(
3290 "could not push to origin/{branch}. {}\nCheck push access and whether the \
3291 branch moved under you.",
3292 e.last_line()
3293 )
3294 });
3295 self.record_write(pushed)
3296 }
3297
3298 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
3306 let head = self.head_oid_checked(cwd)?;
3307 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
3308 return Ok(());
3309 }
3310 bail!(
3311 "refusing to push {head} because it does not contain the reviewed head {expected}. \
3312 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
3313 the intended commits on top of it before resuming.",
3314 cwd.display()
3315 )
3316 }
3317
3318 pub fn push_split_branch(
3327 &self,
3328 cwd: &Path,
3329 branch: &str,
3330 ) -> std::result::Result<(), SplitPushError> {
3331 let remote_ref = format!("refs/heads/{branch}");
3332 let lease = format!("--force-with-lease={remote_ref}:");
3333 let refspec = format!("HEAD:{remote_ref}");
3334 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
3335 Ok(_) => Ok(()),
3336 Err(push_error) => {
3337 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
3338 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
3339 reconcile_failed_split_push(branch, push_error, local, remote)
3340 }
3341 };
3342 self.record_write(result)
3343 }
3344
3345 pub fn gh(&self, args: &[&str]) -> Result<String> {
3348 self.gh_at(None, args)
3349 }
3350
3351 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
3352 let mut argv = vec!["gh".to_string()];
3353 argv.extend(args.iter().map(|s| s.to_string()));
3354 proc::run(
3355 &argv,
3356 &ExecOpts::new()
3357 .cwd(cwd.unwrap_or(&self.root))
3358 .timeout_secs(300),
3359 )
3360 }
3361
3362 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3368 let mut argv = vec!["gh".to_string()];
3369 argv.extend(args.iter().map(|s| s.to_string()));
3370 proc::run(
3371 &argv,
3372 &ExecOpts::new()
3373 .cwd(&self.root)
3374 .timeout_secs(300)
3375 .stdin(stdin),
3376 )
3377 }
3378
3379 pub fn gh_try(&self, args: &[&str]) -> String {
3380 let mut argv = vec!["gh".to_string()];
3381 argv.extend(args.iter().map(|s| s.to_string()));
3382 proc::run(
3383 &argv,
3384 &ExecOpts::new()
3385 .cwd(&self.root)
3386 .check(false)
3387 .timeout_secs(300),
3388 )
3389 .unwrap_or_default()
3390 }
3391
3392 pub fn viewer_login(&self) -> Result<&str> {
3403 if let Some(login) = self.viewer.get() {
3404 return Ok(login);
3405 }
3406 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3407 let login = if !rest.trim().is_empty() {
3408 rest.trim().to_string()
3409 } else {
3410 self.gh(&[
3413 "api",
3414 "graphql",
3415 "-f",
3416 "query={ viewer { login } }",
3417 "--jq",
3418 ".data.viewer.login",
3419 ])
3420 .map_err(|e| {
3421 spar_err!(
3422 "could not find out who `gh` is authenticated as, so spar cannot tell its \
3423 own comments from anybody else's. {}\nRun `gh auth status`.",
3424 e.last_line()
3425 )
3426 })?
3427 .trim()
3428 .to_string()
3429 };
3430 if login.is_empty() {
3431 bail!("`gh` reported an empty login. Run `gh auth status`.");
3432 }
3433 Ok(self.viewer.get_or_init(|| login))
3434 }
3435
3436 pub fn read_issue(&self, number: i64) -> Result<Issue> {
3442 let text = self
3443 .gh(&[
3444 "issue",
3445 "view",
3446 &number.to_string(),
3447 "--json",
3448 "number,title,body,labels,state,stateReason,url",
3449 ])
3450 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3451 serde_json::from_str(&text)
3452 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3453 }
3454
3455 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3456 let mut issues = Vec::new();
3457 for number in numbers {
3458 let text = self
3459 .gh(&[
3460 "issue",
3461 "view",
3462 &number.to_string(),
3463 "--json",
3464 "number,title,body,labels,state,stateReason,url",
3465 ])
3466 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3467 let issue: Issue = serde_json::from_str(&text)
3468 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3469 if issue.is_closed() {
3470 crate::log!("issue #{number} is closed, skipping");
3471 continue;
3472 }
3473 issues.push(issue);
3474 }
3475 if issues.is_empty() {
3476 bail!("no open issues to work on");
3477 }
3478 Ok(issues)
3479 }
3480
3481 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3487 #[derive(Deserialize)]
3488 struct Row {
3489 number: i64,
3490 }
3491 let text = self.gh(&[
3492 kind,
3493 "list",
3494 "--state",
3495 "open",
3496 "--limit",
3497 &FETCH_CEILING.to_string(),
3498 "--json",
3499 "number",
3500 ])?;
3501 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3502 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3503 numbers.sort_unstable();
3504
3505 let noun = if kind == "issue" { "issues" } else { "PRs" };
3506 let found = numbers.len();
3507 if min_number > 0 {
3508 numbers.retain(|n| *n >= min_number);
3509 let skipped = found - numbers.len();
3510 if skipped > 0 {
3511 crate::log!("{skipped} open {noun} below #{min_number} skipped");
3512 }
3513 }
3514 if found >= FETCH_CEILING {
3515 crate::log!(
3516 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3517 considered."
3518 );
3519 }
3520 if numbers.len() > limit {
3521 crate::log!(
3522 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3523 explicitly.",
3524 numbers.len()
3525 );
3526 numbers.truncate(limit);
3527 }
3528 Ok(numbers)
3529 }
3530
3531 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3533 self.open_numbers("issue", limit, min_number)
3534 }
3535
3536 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3537 self.open_numbers("pr", limit, min_number)
3538 }
3539
3540 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3541 self.branch_prs(branch, "open").into_iter().next()
3542 }
3543
3544 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3547 let text = self.gh(&[
3548 "pr",
3549 "list",
3550 "--head",
3551 branch,
3552 "--base",
3553 base,
3554 "--state",
3555 "open",
3556 "--json",
3557 "number,url,title,baseRefName",
3558 ])?;
3559 pr_for_base(&text, branch, base)
3560 }
3561
3562 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3566 self.branch_prs(branch, "all")
3567 }
3568
3569 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3570 let text = self.gh_try(&[
3571 "pr",
3572 "list",
3573 "--head",
3574 branch,
3575 "--state",
3576 state,
3577 "--json",
3578 "number,url,title",
3579 ]);
3580 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3581 }
3582
3583 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3589 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3590 let text = self
3591 .gh(&[
3592 "api",
3593 &path,
3594 "--jq",
3595 "if .pull_request then \"pr\" else \"issue\" end",
3596 ])
3597 .map_err(|e| {
3598 spar_err!(
3599 "no issue or pull request #{number} in this repository. {}",
3600 e.last_line()
3601 )
3602 })?;
3603 match text.trim() {
3604 "pr" => Ok(ItemKind::Pr),
3605 "issue" => Ok(ItemKind::Issue),
3606 other => Err(spar_err!(
3607 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3608 )),
3609 }
3610 }
3611
3612 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
3623 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
3624 if let Some(pr) = mine {
3625 return Ok(Some(pr));
3626 }
3627 let listed = match self.gh(&[
3628 "pr",
3629 "list",
3630 "--state",
3631 "open",
3632 "--limit",
3633 &FETCH_CEILING.to_string(),
3634 "--json",
3635 "number,url,title,baseRefName,closingIssuesReferences",
3636 ]) {
3637 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
3638 spar_err!("could not read the pull request list while looking for #{issue}")
3639 })?,
3640 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
3641 };
3642 if listed.len() >= FETCH_CEILING {
3643 logwarn!(
3644 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
3645 );
3646 }
3647 let default = self.default_branch(base);
3648 if !base.eq_ignore_ascii_case(&default) {
3649 logwarn!(
3653 "#{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."
3654 );
3655 }
3656 Ok(pick_linked_pr(&listed, issue))
3657 }
3658
3659 fn pr_for_branch_failing_closed(
3661 &self,
3662 branch: &str,
3663 base: &str,
3664 issue: i64,
3665 ) -> Result<Option<PrRef>> {
3666 match self.try_pr_for_branch(branch, base) {
3667 Ok(found) => Ok(found),
3668 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
3669 }
3670 }
3671
3672 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
3681 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
3682 return Err(spar_err!(
3683 "could not check whether a pull request already exists for #{issue}: {}. \
3684 Refusing to implement over the top of work that may already be open.",
3685 e.last_line()
3686 ));
3687 }
3688 logwarn!(
3689 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
3690 whether a pull request already exists for it.",
3691 e.last_line()
3692 );
3693 Ok(())
3694 }
3695
3696 pub fn pr_view(&self, number: i64) -> Result<PrView> {
3697 let text = self.gh(&[
3698 "pr",
3699 "view",
3700 &number.to_string(),
3701 "--json",
3702 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3703 ])?;
3704 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3705 }
3706
3707 fn try_pr_state(&self, number: i64) -> Result<String> {
3708 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3709 serde_json::from_str::<Value>(text.trim())
3710 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3711 .get("state")
3712 .and_then(Value::as_str)
3713 .map(str::to_string)
3714 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3715 }
3716
3717 pub fn pr_state(&self, number: i64) -> String {
3718 self.try_pr_state(number).unwrap_or_default()
3719 }
3720
3721 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3723 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3724 let oid = serde_json::from_str::<Value>(&text)
3725 .ok()
3726 .and_then(|value| {
3727 value
3728 .get("headRefOid")
3729 .and_then(Value::as_str)
3730 .map(str::trim)
3731 .filter(|oid| !oid.is_empty())
3732 .map(str::to_string)
3733 })
3734 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3735 Ok(oid)
3736 }
3737
3738 pub fn create_pr(
3739 &self,
3740 cwd: &Path,
3741 branch: &str,
3742 base: &str,
3743 title: &str,
3744 body: &str,
3745 ) -> Result<PrRef> {
3746 let title = self.record_failed_write(self.clean_title(title))?;
3747 let body = self.record_failed_write(self.clean(body))?;
3748 let mut argv = vec![
3749 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3750 ];
3751 if self.drafts != Drafts::Never {
3752 argv.push("--draft");
3753 }
3754 let created = self.gh_at(Some(cwd), &argv);
3755 let found = self.try_pr_for_branch(branch, base);
3756 self.record_write(reconcile_pr_creation(branch, created, found))
3757 }
3758
3759 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3760 let body = signed(&self.record_failed_write(self.clean(body))?);
3761 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3762 if has_exact_comment(&comments, &body) {
3763 return Ok(());
3764 }
3765 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3766 let result = match posted {
3767 Ok(_) => Ok(()),
3768 Err(post_error) => {
3769 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3770 }
3771 };
3772 self.record_write(result)
3773 }
3774
3775 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3776 let body = signed(&self.record_failed_write(self.clean(body))?);
3777 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3778 if has_exact_comment(&comments, &body) {
3779 return Ok(());
3780 }
3781 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3782 let result = match posted {
3783 Ok(_) => Ok(()),
3784 Err(post_error) => {
3785 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3786 }
3787 };
3788 self.record_write(result)
3789 }
3790
3791 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3796 self.comment_issue(number, body)?;
3797 let n = number.to_string();
3798 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3799 Ok(_) => Ok(()),
3800 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3802 spar_err!(
3803 "commented on #{number} but could not close it: {}",
3804 e.last_line()
3805 )
3806 }),
3807 };
3808 self.record_write(closed)
3809 }
3810
3811 pub fn edit_issue_body(
3826 &self,
3827 number: i64,
3828 expected: &str,
3829 body: &str,
3830 inserted: &str,
3831 ) -> Result<()> {
3832 let cleaned = self.record_failed_write(self.clean(inserted))?;
3833 if cleaned.trim() != inserted.trim() {
3834 return self.record_failed_write(Err(spar_err!(
3835 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
3836 )));
3837 }
3838 let current = self.record_failed_write(self.issue_body(number))?;
3839 if current != expected {
3840 return self.record_failed_write(Err(spar_err!(
3841 "the body of #{number} changed since it was read, so it was left alone rather \
3842 than written over."
3843 )));
3844 }
3845 let edited = self.gh_stdin(
3846 &["issue", "edit", &number.to_string(), "--body-file", "-"],
3847 body,
3848 );
3849 let result = match edited {
3850 Ok(_) => Ok(()),
3851 Err(edit_error) => {
3852 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
3853 }
3854 };
3855 self.record_write(result)
3856 }
3857
3858 pub fn issue_body(&self, number: i64) -> Result<String> {
3860 #[derive(Deserialize)]
3861 struct Row {
3862 #[serde(default)]
3863 body: Option<String>,
3864 }
3865 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
3866 let row: Row = serde_json::from_str(text.trim())
3867 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3868 Ok(row.body.unwrap_or_default())
3869 }
3870
3871 pub fn open_issue_rows(&self) -> Vec<Issue> {
3876 let text = self.gh_try(&[
3877 "issue",
3878 "list",
3879 "--state",
3880 "open",
3881 "--limit",
3882 &FETCH_CEILING.to_string(),
3883 "--json",
3884 "number,title,body,labels,state,stateReason,url",
3885 ]);
3886 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
3887 }
3888
3889 pub fn open_pr_rows(&self) -> Vec<PrRow> {
3891 let text = self.gh_try(&[
3892 "pr",
3893 "list",
3894 "--state",
3895 "open",
3896 "--limit",
3897 &FETCH_CEILING.to_string(),
3898 "--json",
3899 "number,title,changedFiles,additions,deletions",
3900 ]);
3901 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
3902 }
3903
3904 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
3905 self.create_issue_apart_from(title, body, None)
3906 }
3907
3908 pub fn create_issue_apart_from(
3909 &self,
3910 title: &str,
3911 body: &str,
3912 apart_from: Option<i64>,
3913 ) -> Result<String> {
3914 let title = self.record_failed_write(self.clean_title(title))?;
3915 let body = self.record_failed_write(self.clean_issue_body(body))?;
3916 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
3917 let result = match created {
3918 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
3919 created => {
3920 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
3921 reconcile_issue_creation(&title, created, found)
3922 }
3923 };
3924 self.record_write(result)
3925 }
3926}
3927
3928#[derive(Debug, Clone)]
3930pub struct ExistingIssue {
3931 pub number: i64,
3932 pub url: String,
3933 pub title: String,
3934 pub body: String,
3935 pub open: bool,
3936}
3937
3938impl Repo {
3939 pub(crate) fn try_exact_issue_apart_from(
3940 &self,
3941 title: &str,
3942 body: &str,
3943 apart_from: Option<i64>,
3944 ) -> Result<Option<ExistingIssue>> {
3945 #[derive(Deserialize)]
3946 #[serde(rename_all = "camelCase")]
3947 struct Row {
3948 number: i64,
3949 #[serde(default)]
3950 title: String,
3951 #[serde(default)]
3952 url: String,
3953 #[serde(default)]
3954 body: Option<String>,
3955 #[serde(default)]
3956 state: String,
3957 }
3958
3959 let text = self.gh(&[
3960 "issue",
3961 "list",
3962 "--state",
3963 "all",
3964 "--limit",
3965 "100",
3966 "--json",
3967 "number,title,url,body,state,stateReason",
3968 ])?;
3969 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3970 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
3971 Ok(rows
3972 .into_iter()
3973 .filter(|row| Some(row.number) != apart_from)
3974 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
3975 .map(|row| ExistingIssue {
3976 number: row.number,
3977 url: row.url,
3978 title: row.title,
3979 body: row.body.unwrap_or_default(),
3980 open: row.state.eq_ignore_ascii_case("open"),
3981 }))
3982 }
3983
3984 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
3992 self.find_similar_issue_apart_from(title, body, None)
3993 }
3994
3995 pub fn find_similar_issue_apart_from(
4003 &self,
4004 title: &str,
4005 body: &str,
4006 apart_from: Option<i64>,
4007 ) -> Option<ExistingIssue> {
4008 self.try_similar_issue(title, body, apart_from, true)
4009 .ok()
4010 .flatten()
4011 }
4012
4013 pub fn try_find_similar_issue_apart_from(
4015 &self,
4016 title: &str,
4017 body: &str,
4018 apart_from: Option<i64>,
4019 ) -> Result<Option<ExistingIssue>> {
4020 self.try_similar_issue(title, body, apart_from, false)
4021 }
4022
4023 fn try_similar_issue(
4025 &self,
4026 title: &str,
4027 body: &str,
4028 apart_from: Option<i64>,
4029 include_closed: bool,
4030 ) -> Result<Option<ExistingIssue>> {
4031 #[derive(Deserialize)]
4032 #[serde(rename_all = "camelCase")]
4033 struct Row {
4034 number: i64,
4035 #[serde(default)]
4036 title: String,
4037 #[serde(default)]
4038 url: String,
4039 #[serde(default)]
4040 body: String,
4041 #[serde(default)]
4042 state: String,
4043 }
4044 if title.trim().is_empty() {
4045 return Ok(None);
4046 }
4047 let query: String = title
4050 .chars()
4051 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4052 .take(120)
4053 .collect();
4054 let state = if include_closed { "all" } else { "open" };
4063 let text = self.gh(&[
4064 "issue",
4065 "list",
4066 "--state",
4067 state,
4068 "--limit",
4069 "100",
4070 "--search",
4071 query.trim(),
4072 "--json",
4073 "number,title,url,body,state,stateReason",
4074 ])?;
4075 let rows: Vec<Row> = serde_json::from_str(text.trim())
4076 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
4077
4078 Ok(rows
4079 .into_iter()
4080 .filter(|row| Some(row.number) != apart_from)
4081 .find(|row| {
4082 row.title.trim().eq_ignore_ascii_case(title.trim())
4083 || textsim::covers(title, body, &row.title, &row.body)
4084 })
4085 .map(|row| ExistingIssue {
4086 number: row.number,
4087 url: row.url,
4088 title: row.title,
4089 open: row.state.eq_ignore_ascii_case("open"),
4090 body: row.body,
4091 }))
4092 }
4093
4094 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
4096 #[derive(Deserialize)]
4097 struct Row {
4098 title: String,
4099 url: String,
4100 }
4101 let needle = title.trim().to_lowercase();
4102 if needle.is_empty() {
4103 return None;
4104 }
4105 let query: String = title
4107 .chars()
4108 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4109 .take(120)
4110 .collect();
4111 let text = self.gh_try(&[
4112 "issue",
4113 "list",
4114 "--state",
4115 "all",
4116 "--limit",
4117 "100",
4118 "--search",
4119 query.trim(),
4120 "--json",
4121 "number,title,url",
4122 ]);
4123 serde_json::from_str::<Vec<Row>>(text.trim())
4124 .ok()?
4125 .into_iter()
4126 .find(|row| row.title.trim().to_lowercase() == needle)
4127 .map(|row| row.url)
4128 }
4129
4130 pub fn mark_ready(&self, number: i64) -> bool {
4138 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
4139 Ok(_) => true,
4140 Err(e) => {
4141 logdim!(
4142 "PR #{number} is approved but could not be taken out of draft: {}",
4143 e.last_line()
4144 );
4145 false
4146 }
4147 }
4148 }
4149
4150 pub fn merge_pr(&self, number: i64) -> Result<()> {
4154 let n = number.to_string();
4155 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4156 Ok(_) => Ok(()),
4157 Err(e) => {
4158 if self.pr_state(number) == "MERGED" {
4159 logdim!(
4160 "PR #{number} merged; branch cleanup did not finish: {}",
4161 e.last_line()
4162 );
4163 Ok(())
4164 } else {
4165 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4166 }
4167 }
4168 };
4169 self.record_write(merged)
4170 }
4171
4172 pub fn merge_pr_at_head(
4174 &self,
4175 number: i64,
4176 expected_head: &str,
4177 delete_branch: bool,
4178 ) -> Result<()> {
4179 let n = number.to_string();
4180 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4181 Ok(_) => Ok(()),
4182 Err(e) => {
4183 if self.pr_state(number) == "MERGED" {
4184 logdim!(
4185 "PR #{number} merged; branch cleanup did not finish: {}",
4186 e.last_line()
4187 );
4188 Ok(())
4189 } else {
4190 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4191 }
4192 }
4193 };
4194 self.record_write(merged)
4195 }
4196
4197 pub fn followups_path(&self) -> PathBuf {
4202 self.root.join(STATE_DIR).join("followups.md")
4203 }
4204
4205 pub fn worked_followups_path(&self) -> PathBuf {
4213 self.root.join(STATE_DIR).join("followups.done.md")
4214 }
4215
4216 pub fn spend_path(&self) -> PathBuf {
4219 self.root.join(STATE_DIR).join("state").join("spend.json")
4220 }
4221
4222 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4228 if calls.is_empty() {
4229 return Ok(());
4230 }
4231 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4232 .ok()
4233 .and_then(|text| serde_json::from_str(&text).ok())
4234 .unwrap_or_default();
4235 all.extend(calls.iter().cloned());
4236 if let Some(parent) = self.spend_path().parent() {
4237 let _ = std::fs::create_dir_all(parent);
4238 }
4239 write_json_atomic(&self.spend_path(), &all)
4240 }
4241
4242 pub fn implementors_path(&self) -> PathBuf {
4248 self.root
4249 .join(STATE_DIR)
4250 .join("state")
4251 .join("implementors.json")
4252 }
4253
4254 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4255 std::fs::read_to_string(self.implementors_path())
4256 .ok()
4257 .and_then(|text| serde_json::from_str(&text).ok())
4258 .unwrap_or_default()
4259 }
4260
4261 pub fn remember_implementor(&self, issue: i64, agent: &str) {
4264 let mut known = self.read_implementors();
4265 known.insert(issue, agent.to_string());
4266 if let Some(parent) = self.implementors_path().parent() {
4267 let _ = std::fs::create_dir_all(parent);
4268 }
4269 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4270 logdim!("could not record who implemented #{issue}: {e}");
4271 }
4272 }
4273
4274 pub fn contested_path(&self) -> PathBuf {
4279 self.root
4280 .join(STATE_DIR)
4281 .join("state")
4282 .join("contested.json")
4283 }
4284
4285 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4287 std::fs::read_to_string(self.contested_path())
4288 .ok()
4289 .and_then(|text| serde_json::from_str(&text).ok())
4290 .unwrap_or_default()
4291 }
4292
4293 pub fn remember_contested(&self, items: &[ContestedItem]) {
4298 if items.is_empty() {
4299 return;
4300 }
4301 let mut known = self.read_contested();
4302 for item in items {
4303 known.insert(
4304 item.issue,
4305 Contested {
4306 title: item.title.clone(),
4307 positions: item.positions.clone(),
4308 reasons: item.reasons.clone(),
4309 },
4310 );
4311 }
4312 if let Some(parent) = self.contested_path().parent() {
4313 let _ = std::fs::create_dir_all(parent);
4314 }
4315 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4316 logdim!("could not record the contested issues: {e}");
4317 }
4318 }
4319
4320 pub fn forget_contested(&self, numbers: &[i64]) {
4322 let mut known = self.read_contested();
4323 let before = known.len();
4324 known.retain(|number, _| !numbers.contains(number));
4325 if known.len() == before {
4326 return;
4327 }
4328 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4329 logdim!("could not update the contested issues: {e}");
4330 }
4331 }
4332
4333 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4334 self.root
4335 .join(STATE_DIR)
4336 .join("state")
4337 .join(format!("checkin-{number}.json"))
4338 }
4339
4340 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4355 let path = self.followups_path();
4356 let heading = format!("## {}", title.trim());
4357 for seen in [&path, &self.worked_followups_path()] {
4358 if let Ok(existing) = std::fs::read_to_string(seen) {
4359 if has_heading(&existing, title) {
4360 logdim!("follow-up already noted: {title}");
4361 return Followup::Covered(format!("note: {}", title.trim()));
4362 }
4363 }
4364 }
4365 if let Some(parent) = path.parent() {
4366 let _ = std::fs::create_dir_all(parent);
4367 }
4368 use std::io::Write;
4369 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4376 match std::fs::OpenOptions::new()
4377 .create(true)
4378 .append(true)
4379 .open(&path)
4380 {
4381 Ok(mut file) => match file.write_all(entry.as_bytes()) {
4382 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4383 Err(e) => {
4384 logdim!("could not write {}: {e}", path.display());
4385 Followup::Failed
4386 }
4387 },
4388 Err(e) => {
4389 logdim!("could not write {}: {e}", path.display());
4390 Followup::Failed
4391 }
4392 }
4393 }
4394
4395 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4400 let path = self.worked_followups_path();
4401 if let Some(parent) = path.parent() {
4402 let _ = std::fs::create_dir_all(parent);
4403 }
4404 use std::io::Write;
4405 let entry = format!(
4406 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4407 title.trim(),
4408 body.trim()
4409 );
4410 if let Ok(mut file) = std::fs::OpenOptions::new()
4411 .create(true)
4412 .append(true)
4413 .open(&path)
4414 {
4415 let _ = file.write_all(entry.as_bytes());
4416 }
4417 }
4418
4419 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4428 self.root
4429 .join(STATE_DIR)
4430 .join("reviews")
4431 .join(format!("pr-{number}.md"))
4432 }
4433
4434 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4440 let path = self.pending_comment_path(number);
4441 if let Some(parent) = path.parent() {
4442 std::fs::create_dir_all(parent)
4443 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4444 }
4445 std::fs::write(&path, text)
4446 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4447 Ok(path)
4448 }
4449
4450 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4451 std::fs::read_to_string(self.pending_comment_path(number)).ok()
4452 }
4453
4454 pub fn state_path(&self, number: i64) -> PathBuf {
4455 self.root
4456 .join(STATE_DIR)
4457 .join("state")
4458 .join(format!("pr-{number}.json"))
4459 }
4460
4461 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4462 let path = self.state_path(number);
4463 let text = std::fs::read_to_string(&path).ok()?;
4464 match serde_json::from_str(&text) {
4465 Ok(state) => Some(state),
4466 Err(_) => {
4467 logdim!("could not read {}, starting fresh", path.display());
4468 None
4469 }
4470 }
4471 }
4472
4473 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4474 if let Some(local) = self.read_local_state(pr.number) {
4475 return Some(local);
4476 }
4477 if self.state_store.writes_pr() {
4478 return self.read_pr_state(pr.number);
4479 }
4480 None
4481 }
4482
4483 pub(crate) fn read_state_for_head(
4484 &self,
4485 pr: &PrView,
4486 actual_head: &str,
4487 ) -> Option<PersistedState> {
4488 let local = self
4489 .state_store
4490 .writes_local()
4491 .then(|| self.read_local_state(pr.number))
4492 .flatten();
4493 let remote = self
4494 .state_store
4495 .writes_pr()
4496 .then(|| self.read_pr_state(pr.number))
4497 .flatten();
4498 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4499 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4500 self.remember_checkpoint(pr.number, checkpoint);
4501 }
4502 choose_state_for_head(candidates, actual_head)
4503 }
4504
4505 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4506 self.try_read_pr_state(number).ok().flatten()
4507 }
4508
4509 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4510 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4511 if let Some(state) = parse_state_comment(&body) {
4512 return Ok(Some(state));
4513 }
4514 }
4515 Ok(None)
4516 }
4517
4518 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4519 let remote_state = if self.state_store.writes_pr() {
4520 self.try_read_pr_state(number)
4521 } else {
4522 Ok(None)
4523 };
4524 self.write_state_after_remote_read(number, state, remote_state)
4525 }
4526
4527 fn write_state_after_remote_read(
4528 &self,
4529 number: i64,
4530 state: &PersistedState,
4531 remote_state: Result<Option<PersistedState>>,
4532 ) -> Result<()> {
4533 let remote_checkpoint = if self.state_store.writes_pr() {
4534 self.record_failed_write(remote_state)?
4535 .map(|saved| saved.checkpoint)
4536 .unwrap_or_default()
4537 } else {
4538 0
4539 };
4540 let local_checkpoint = self
4541 .state_store
4542 .writes_local()
4543 .then(|| self.read_local_state(number))
4544 .flatten()
4545 .map(|saved| saved.checkpoint)
4546 .unwrap_or_default();
4547 let mut stamped = state.clone();
4548 stamped.checkpoint = state
4549 .checkpoint
4550 .max(local_checkpoint)
4551 .max(remote_checkpoint)
4552 .max(self.remembered_checkpoint(number))
4553 .saturating_add(1);
4554 self.remember_checkpoint(number, stamped.checkpoint);
4555 if self.state_store.writes_local() {
4556 write_json_atomic(&self.state_path(number), &stamped)?;
4557 }
4558 if self.state_store.writes_pr() {
4559 self.write_pr_state(number, &stamped)?;
4560 }
4561 Ok(())
4562 }
4563
4564 fn remembered_checkpoint(&self, number: i64) -> u64 {
4565 self.checkpoints
4566 .lock()
4567 .unwrap_or_else(std::sync::PoisonError::into_inner)
4568 .get(&number)
4569 .copied()
4570 .unwrap_or_default()
4571 }
4572
4573 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4574 let mut checkpoints = self
4575 .checkpoints
4576 .lock()
4577 .unwrap_or_else(std::sync::PoisonError::into_inner);
4578 let saved = checkpoints.entry(number).or_default();
4579 *saved = (*saved).max(checkpoint);
4580 }
4581
4582 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4583 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4587 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4588 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4589 if let Some(id) = comment_id {
4590 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4591 let field = format!("body={body}");
4592 let written = self
4593 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4594 .map(|_| ());
4595 return self.record_write(written);
4596 }
4597 let written = self
4598 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4599 .map(|_| ());
4600 self.record_write(written)
4601 }
4602
4603 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4611 self.try_issue_comments(number).unwrap_or_default()
4612 }
4613
4614 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4615 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4616 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4617 }
4618
4619 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4626 let viewer = self.viewer_login()?.to_string();
4627 let mut mine = Vec::new();
4628 let mut theirs = Vec::new();
4629 for comment in self.try_issue_comments(number)? {
4630 let Some(body) = comment.get("body").and_then(Value::as_str) else {
4631 continue;
4632 };
4633 if !body.contains("spar:state") {
4634 continue;
4635 }
4636 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4637 continue;
4638 };
4639 let author = comment
4640 .get("user")
4641 .and_then(|u| u.get("login"))
4642 .and_then(Value::as_str)
4643 .unwrap_or_default();
4644 if is_state_comment(body, author, &viewer) {
4645 mine.push((id, body.to_string()));
4646 } else {
4647 theirs.push(format!("@{author}"));
4648 }
4649 }
4650 if !theirs.is_empty() {
4651 logdim!(
4652 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4653 own state block is patched or deleted.",
4654 theirs.len(),
4655 theirs.join(", ")
4656 );
4657 }
4658 Ok(mine)
4659 }
4660
4661 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4662 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4663 }
4664
4665 pub fn clear_state(&self, number: i64) {
4667 let path = self.state_path(number);
4668 let _ = std::fs::remove_file(&path);
4669 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4670 }
4671
4672 pub fn prune_state(&self) -> Vec<String> {
4676 let base = self.root.join(STATE_DIR).join("state");
4677 let Ok(entries) = std::fs::read_dir(&base) else {
4678 return Vec::new();
4679 };
4680 let mut names: Vec<String> = entries
4681 .flatten()
4682 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4683 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4684 .collect();
4685 names.sort();
4686
4687 let mut removed = Vec::new();
4688 for name in names {
4689 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4690 continue;
4691 };
4692 if is_finished(&self.pr_state(number)) {
4693 let _ = std::fs::remove_file(base.join(&name));
4694 removed.push(format!("state {name}"));
4695 }
4696 }
4697 removed
4698 }
4699
4700 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4704 #[derive(Deserialize)]
4705 struct Row {
4706 number: i64,
4707 }
4708 let numbers = match numbers {
4709 Some(numbers) => numbers,
4710 None => {
4711 let listed: Result<Vec<i64>> = (|| {
4712 let text = self.gh(&[
4713 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4714 ])?;
4715 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4716 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4717 Ok(rows.into_iter().map(|row| row.number).collect())
4718 })();
4719 match self.record_failed_write(listed) {
4720 Ok(numbers) => numbers,
4721 Err(e) => {
4722 logdim!("could not inspect pull requests for state cleanup: {e}");
4723 return Vec::new();
4724 }
4725 }
4726 }
4727 };
4728
4729 let mut removed = Vec::new();
4730 for number in numbers {
4731 let state = match self.record_failed_write(self.try_pr_state(number)) {
4732 Ok(state) => state,
4733 Err(e) => {
4734 logdim!("could not inspect PR #{number} for state cleanup: {e}");
4735 continue;
4736 }
4737 };
4738 if !is_finished(&state) {
4739 continue;
4740 }
4741 let comments = match self.record_failed_write(self.try_state_comments(number)) {
4742 Ok(comments) => comments,
4743 Err(e) => {
4744 logdim!("could not inspect state comments on PR #{number}: {e}");
4745 continue;
4746 }
4747 };
4748 for (id, _) in comments {
4749 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4750 let deleted = self
4751 .gh(&["api", "-X", "DELETE", &path, "--silent"])
4752 .map(|_| ());
4753 match self.record_write(deleted) {
4754 Ok(()) => removed.push(format!("state comment on PR #{number}")),
4755 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
4756 }
4757 }
4758 }
4759 removed
4760 }
4761
4762 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
4769 let base = self.root.join(WORKTREE_DIR);
4770 let mut removed = Vec::new();
4771 let known = self.known_branches();
4772
4773 if let Ok(entries) = std::fs::read_dir(&base) {
4774 let mut names: Vec<String> = entries
4775 .flatten()
4776 .filter(|e| e.path().is_dir())
4777 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4778 .collect();
4779 names.sort();
4780
4781 for name in names {
4782 if let Some(rest) = name.strip_prefix("review-") {
4785 let number: i64 = rest.parse().unwrap_or(-1);
4786 if !(force_all || is_finished(&self.pr_state(number))) {
4787 continue;
4788 }
4789 let path = base.join(&name);
4790 if force_all {
4791 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4792 if !belongs {
4793 return Ok(false);
4794 }
4795 let local_ref = review_ref(number);
4796 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4797 return Ok(false);
4798 }
4799 let head = self.head_oid_checked(&path)?;
4800 let recorded = self
4801 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4802 .trim()
4803 .to_string();
4804 Ok(head == recorded)
4805 });
4806 match owned {
4807 Ok(true) => {}
4808 Ok(false) => {
4809 logdim!(
4810 "kept {} because no matching SPAR review reference proves \
4811 ownership",
4812 path.display()
4813 );
4814 continue;
4815 }
4816 Err(e) => {
4817 logdim!(
4818 "kept {} because review ownership could not be verified: {}",
4819 path.display(),
4820 e.last_line()
4821 );
4822 continue;
4823 }
4824 }
4825 } else {
4826 if let Err(e) = self.refuse_review_worktree_changes(number) {
4827 logdim!(
4828 "kept {} because its review state could not be verified as \
4829 disposable: {}",
4830 path.display(),
4831 e.last_line()
4832 );
4833 continue;
4834 }
4835 }
4836 if force_all {
4837 if self.remove_worktree_at_force(&path) {
4838 self.git_try(&["update-ref", "-d", &review_ref(number)]);
4839 }
4840 } else {
4841 self.release_review_worktree(number);
4842 }
4843 if !path.exists() {
4844 removed.push(name);
4845 }
4846 continue;
4847 }
4848 let branch = format!("{}{name}", self.branch_prefix);
4849 if !(force_all || self.worktree_is_done(&branch)) {
4850 continue;
4851 }
4852 if !known.contains_key(&branch) {
4853 logdim!("kept {branch} because it has no branch record");
4854 continue;
4855 }
4856 let path = base.join(&name);
4857 if !force_all {
4858 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
4859 Ok(true) => {
4860 logdim!(
4861 "kept {} because it contains uncommitted changes or ignored files",
4862 path.display()
4863 );
4864 continue;
4865 }
4866 Err(e) => {
4867 logdim!(
4868 "kept {} because its Git state could not be checked: {}",
4869 path.display(),
4870 e.last_line()
4871 );
4872 continue;
4873 }
4874 Ok(false) => {}
4875 }
4876 match self.branch_deletion_is_safe(&branch) {
4877 Ok(true) => {}
4878 Ok(false) => {
4879 logdim!(
4880 "kept {branch} because no surviving ref preserves its tip or \
4881 reflog-only commits"
4882 );
4883 continue;
4884 }
4885 Err(e) => {
4886 logdim!(
4887 "kept {branch} because preservation could not be verified: {}",
4888 e.last_line()
4889 );
4890 continue;
4891 }
4892 }
4893 }
4894 let removed_worktree = if force_all {
4895 self.remove_worktree_at_force(&path)
4896 } else {
4897 match self.remove_worktree_at(&path) {
4898 Ok(removed) => removed,
4899 Err(error) => {
4900 logdim!(
4901 "kept {branch} and {} because removal did not reach a confirmed \
4902 quiet point: {}",
4903 path.display(),
4904 error.last_line()
4905 );
4906 false
4907 }
4908 }
4909 };
4910 if !removed_worktree {
4911 continue;
4912 }
4913 if force_all {
4914 self.git_try(&["branch", "-D", &branch]);
4915 self.forget_branch(&branch);
4916 } else {
4917 match self.delete_branch_if_safe(&branch) {
4918 Ok(true) => self.forget_branch(&branch),
4919 Ok(false) => logdim!(
4920 "kept {branch} because its tip or reflog changed before deletion"
4921 ),
4922 Err(error) => logdim!(
4923 "kept {branch} because deletion safety could not be rechecked: {}",
4924 error.last_line()
4925 ),
4926 }
4927 }
4928 removed.push(name);
4929 }
4930 }
4931 removed.extend(self.prune_branches(force_all));
4932 removed
4933 }
4934
4935 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
4942 let known = self.known_branches();
4943 let branches: Vec<String> = known.keys().cloned().collect();
4944 if branches.is_empty() {
4945 return Vec::new();
4946 }
4947
4948 let checked_out: Vec<String> = self
4949 .git_try(&["worktree", "list", "--porcelain"])
4950 .lines()
4951 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
4952 .collect();
4953
4954 let existing: Vec<String> = self
4957 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
4958 .lines()
4959 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
4960 .collect();
4961
4962 let mut removed = Vec::new();
4963 for branch in branches {
4964 if !existing.contains(&branch) {
4965 self.forget_branch(&branch); continue;
4967 }
4968 if checked_out.contains(&branch) {
4969 continue;
4970 }
4971 if !(force_all || self.worktree_is_done(&branch)) {
4972 continue;
4973 }
4974 if !force_all {
4975 let Some(_record) = known.get(&branch) else {
4976 continue;
4977 };
4978 match self.branch_deletion_is_safe(&branch) {
4979 Ok(true) => {}
4980 Ok(false) => {
4981 logdim!(
4982 "kept {branch} because no surviving ref preserves its tip or \
4983 reflog-only commits"
4984 );
4985 continue;
4986 }
4987 Err(e) => {
4988 logdim!(
4989 "kept {branch} because preservation could not be verified: {}",
4990 e.last_line()
4991 );
4992 continue;
4993 }
4994 }
4995 }
4996 let deleted = if force_all {
4997 self.git(&["branch", "-D", &branch]).map(|_| true)
4998 } else {
4999 self.delete_branch_if_safe(&branch)
5000 };
5001 match deleted {
5002 Ok(true) => {
5003 self.forget_branch(&branch);
5004 removed.push(format!("branch {branch}"));
5005 }
5006 Ok(false) => {
5007 logdim!("kept {branch} because its tip or reflog changed before deletion");
5008 }
5009 Err(e) => {
5010 logdim!("could not delete {branch}: {}", e.last_line());
5013 }
5014 }
5015 }
5016 removed
5017 }
5018
5019 fn worktree_is_done(&self, branch: &str) -> bool {
5021 #[derive(Deserialize)]
5022 struct Row {
5023 state: String,
5024 }
5025 let entry = branch
5026 .strip_prefix(self.branch_prefix.as_str())
5027 .unwrap_or(branch);
5028 if let Some(rest) = entry.strip_prefix("pr-") {
5029 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
5030 }
5031 if entry.starts_with("issue-") || entry.starts_with("split-") {
5035 let text = self.gh_try(&[
5036 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
5037 ]);
5038 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5039 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
5040 }
5041 false
5042 }
5043}
5044
5045pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5057 match probe() {
5058 Ok(value) => Ok(value),
5059 Err(first) => {
5060 logdim!("retrying a worktree probe after: {}", first.last_line());
5061 probe()
5062 }
5063 }
5064}
5065
5066pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5072 let root = std::fs::canonicalize(cwd)
5073 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5074 let mut files = BTreeMap::new();
5075 let mut visited = BTreeSet::new();
5076 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5077 Ok(AttributeState { files })
5078}
5079
5080fn collect_attribute_files(
5081 root: &Path,
5082 repository: &Path,
5083 prefix: &Path,
5084 visited: &mut BTreeSet<PathBuf>,
5085 files: &mut BTreeMap<PathBuf, [u8; 32]>,
5086) -> Result<()> {
5087 let canonical = std::fs::canonicalize(repository)
5088 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5089 if !visited.insert(canonical) {
5090 bail!("submodule recursion revisited {}", repository.display());
5091 }
5092 let entries = index_entries(repository)?;
5093 let mut paths: BTreeSet<PathBuf> = entries
5094 .iter()
5095 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5096 .map(|entry| entry.path.clone())
5097 .collect();
5098 let untracked = run_git_bytes(
5104 repository,
5105 &[
5106 "ls-files",
5107 "--others",
5108 "--exclude-standard",
5109 "-z",
5110 "--",
5111 ".gitattributes",
5112 ":(glob)**/.gitattributes",
5113 ],
5114 )?;
5115 if !untracked.is_empty() && !untracked.ends_with(&[0]) {
5116 bail!(
5117 "git returned an unterminated attribute-file listing for {}",
5118 repository.display()
5119 );
5120 }
5121 for raw in untracked
5122 .split(|byte| *byte == 0)
5123 .filter(|record| !record.is_empty())
5124 {
5125 paths.insert(safe_git_path(raw, "attribute")?);
5126 }
5127 for path in paths {
5128 let from_root = prefix.join(&path);
5129 let state = attribute_file_fingerprint(&root.join(&from_root))?;
5130 files.insert(from_root, state);
5131 }
5132 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5133 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5134 continue;
5135 };
5136 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5137 }
5138 Ok(())
5139}
5140
5141pub(crate) fn uncertain_worktree_change(
5144 cwd: &Path,
5145 message: impl Into<String>,
5146) -> crate::error::SparError {
5147 let message = message.into();
5148 let marker = write_recovery_marker(cwd, &message);
5149 let note = match marker {
5150 Ok(path) => format!(" Recovery marker: {}.", path.display()),
5151 Err(e) => format!(
5152 " A recovery marker could not be written: {}.",
5153 e.last_line()
5154 ),
5155 };
5156 crate::error::SparError::uncertain_write(format!("{message}{note}"))
5157}
5158
5159fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5160 use std::sync::atomic::{AtomicU32, Ordering};
5161 static NEXT: AtomicU32 = AtomicU32::new(0);
5162 for _ in 0..1000 {
5163 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5164 let path = cwd.join(format!(
5165 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5166 std::process::id()
5167 ));
5168 let mut options = OpenOptions::new();
5169 options.write(true).create_new(true);
5170 #[cfg(unix)]
5171 {
5172 use std::os::unix::fs::OpenOptionsExt;
5173 options.mode(0o600);
5174 }
5175 match options.open(&path) {
5176 Ok(mut file) => {
5177 file.write_all(detail.as_bytes())
5178 .and_then(|_| file.write_all(b"\n"))
5179 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5180 return Ok(path);
5181 }
5182 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5183 Err(e) => {
5184 return Err(spar_err!(
5185 "could not create a recovery marker in {}: {e}",
5186 cwd.display()
5187 ))
5188 }
5189 }
5190 }
5191 bail!(
5192 "could not choose a free recovery marker name in {}",
5193 cwd.display()
5194 )
5195}
5196
5197fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5202 let mut argv = vec![
5203 "git".to_string(),
5204 "-c".to_string(),
5205 "maintenance.auto=false".to_string(),
5206 "-c".to_string(),
5207 "gc.auto=0".to_string(),
5208 ];
5209 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5210 argv
5211}
5212
5213fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5214 let mut argv = git_without_maintenance_argv(&[]);
5215 argv.extend([
5216 "-c".to_string(),
5217 "core.fsmonitor=".to_string(),
5218 "-c".to_string(),
5219 "commit.gpgsign=false".to_string(),
5220 "-c".to_string(),
5221 "core.hooksPath=/dev/null".to_string(),
5222 ]);
5223 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5224 argv
5225}
5226
5227pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5234 let root = std::fs::canonicalize(cwd)
5235 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5236 let mut files = BTreeMap::new();
5237 let mut ignored = BTreeSet::new();
5238 let mut visited = BTreeSet::new();
5239 collect_untracked_files(
5240 &root,
5241 &root,
5242 Path::new(""),
5243 &mut visited,
5244 &mut files,
5245 &mut ignored,
5246 )?;
5247 Ok(IgnoredState { files, ignored })
5248}
5249
5250fn collect_untracked_files(
5251 root: &Path,
5252 repository: &Path,
5253 prefix: &Path,
5254 visited: &mut BTreeSet<PathBuf>,
5255 files: &mut BTreeMap<PathBuf, UntrackedFile>,
5256 ignored: &mut BTreeSet<PathBuf>,
5257) -> Result<()> {
5258 let canonical = std::fs::canonicalize(repository)
5259 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5260 if !visited.insert(canonical.clone()) {
5261 bail!("submodule recursion revisited {}", canonical.display());
5262 }
5263 let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
5264 if !listed.is_empty() && !listed.ends_with(&[0]) {
5265 bail!(
5266 "git returned an unterminated untracked-file list for {}",
5267 repository.display()
5268 );
5269 }
5270
5271 for raw in listed
5272 .split(|byte| *byte == 0)
5273 .filter(|raw| !raw.is_empty())
5274 {
5275 let (relative, nested) = untracked_record(raw, "untracked")?;
5276 let from_root = prefix.join(&relative);
5277 if is_spar_own_path(&from_root) {
5278 continue;
5279 }
5280 let absolute = root.join(&from_root);
5281 let fingerprint = if nested {
5282 nested_repository_fingerprint(&absolute)?
5283 } else {
5284 ignored_file_fingerprint(&absolute)?
5285 };
5286 if files.insert(from_root.clone(), fingerprint).is_some() {
5287 bail!(
5288 "git returned the untracked path more than once: {:?}",
5289 from_root
5290 );
5291 }
5292 }
5293
5294 let ignored_listed = run_git_bytes(
5295 repository,
5296 &[
5297 "ls-files",
5298 "--others",
5299 "--ignored",
5300 "--exclude-standard",
5301 "-z",
5302 ],
5303 )?;
5304 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5305 bail!(
5306 "git returned an unterminated ignored-file list for {}",
5307 repository.display()
5308 );
5309 }
5310 for raw in ignored_listed
5316 .split(|byte| *byte == 0)
5317 .filter(|raw| !raw.is_empty())
5318 {
5319 let (relative, nested) = untracked_record(raw, "ignored")?;
5320 let from_root = prefix.join(relative);
5321 if is_spar_own_path(&from_root) {
5322 continue;
5323 }
5324 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5325 let absolute = root.join(&from_root);
5326 let fingerprint = if nested {
5327 nested_repository_fingerprint(&absolute)?
5328 } else {
5329 ignored_file_fingerprint(&absolute)?
5330 };
5331 slot.insert(fingerprint);
5332 }
5333 if !ignored.insert(from_root.clone()) {
5334 bail!(
5335 "git returned the ignored path more than once: {:?}",
5336 from_root
5337 );
5338 }
5339 }
5340
5341 for link in gitlinks(repository)? {
5342 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5343 continue;
5344 };
5345 collect_untracked_files(
5346 root,
5347 &submodule,
5348 &prefix.join(&link.path),
5349 visited,
5350 files,
5351 ignored,
5352 )?;
5353 }
5354 Ok(())
5355}
5356
5357fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5358 let argv = git_without_automation_argv(args);
5359 proc::run_bytes(
5360 &argv,
5361 &ExecOpts::new()
5362 .cwd(cwd)
5363 .timeout_secs(30)
5364 .stop_descendants(true),
5365 )
5366}
5367
5368fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5369 let argv = git_without_automation_argv(args);
5370 proc::run(
5371 &argv,
5372 &ExecOpts::new()
5373 .cwd(cwd)
5374 .timeout_secs(30)
5375 .stop_descendants(true),
5376 )
5377}
5378
5379fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5380 let path = path.to_str().ok_or_else(|| {
5381 spar_err!(
5382 "cannot verify filtered content for a non-UTF-8 path in {}",
5383 cwd.display()
5384 )
5385 })?;
5386 let path_arg = format!("--path={path}");
5387 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5388 Ok(Sha256::digest(bytes).into())
5389}
5390
5391fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5392 let relative = path_from_git_bytes(raw)?;
5393 if relative.is_absolute()
5394 || relative.components().any(|component| {
5395 matches!(
5396 component,
5397 std::path::Component::ParentDir
5398 | std::path::Component::RootDir
5399 | std::path::Component::Prefix(_)
5400 )
5401 })
5402 {
5403 bail!("git returned an unsafe {kind} path: {:?}", relative);
5404 }
5405 Ok(relative)
5406}
5407
5408fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5417 let nested = raw.last() == Some(&b'/');
5418 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5419 if trimmed.is_empty() {
5420 bail!("git returned an empty {kind} path");
5421 }
5422 Ok((safe_git_path(trimmed, kind)?, nested))
5423}
5424
5425fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5426 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5427 if !listed.is_empty() && !listed.ends_with(&[0]) {
5428 bail!(
5429 "git returned an unterminated index listing for {}",
5430 cwd.display()
5431 );
5432 }
5433 let mut entries = Vec::new();
5434 for record in listed
5435 .split(|byte| *byte == 0)
5436 .filter(|record| !record.is_empty())
5437 {
5438 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5439 bail!(
5440 "git returned a malformed index record for {}",
5441 cwd.display()
5442 );
5443 };
5444 let header = &record[..tab];
5445 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5446 if fields.len() != 3 {
5447 bail!(
5448 "git returned a malformed index header for {}",
5449 cwd.display()
5450 );
5451 }
5452 if fields[2] != b"0" {
5453 continue;
5454 }
5455 let mode = std::str::from_utf8(fields[0])
5456 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5457 .to_string();
5458 let oid = std::str::from_utf8(fields[1])
5459 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5460 .to_string();
5461 entries.push(IndexEntry {
5462 path: safe_git_path(&record[tab + 1..], "index")?,
5463 mode,
5464 oid,
5465 });
5466 }
5467 Ok(entries)
5468}
5469
5470fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5471 let untracked = run_git_bytes(
5472 cwd,
5473 &[
5474 "ls-files",
5475 "--others",
5476 "-z",
5477 "--",
5478 ".gitattributes",
5479 ":(glob)**/.gitattributes",
5480 ],
5481 )?;
5482 if !untracked.is_empty() {
5483 return Ok(true);
5484 }
5485
5486 let index = index_entries(cwd)?
5487 .into_iter()
5488 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5489 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5490 .collect::<BTreeMap<_, _>>();
5491 let head = tree_entries(cwd, "HEAD")?
5492 .into_iter()
5493 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5494 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5495 .collect::<BTreeMap<_, _>>();
5496 if index != head {
5497 return Ok(true);
5498 }
5499
5500 let effective = check_attributes(cwd, index.keys().cloned())?;
5501 let config = CheckoutConfig::read(cwd)?;
5502 for (path, (_mode, oid)) in index {
5503 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5504 return Ok(true);
5505 };
5506 let attributes = effective
5507 .get(&path)
5508 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5509 if allows_expected_crlf(&config, attributes)? {
5510 if worktree.mode == "120000" {
5511 return Ok(true);
5512 }
5513 let (normalized, every_lf_was_crlf) =
5514 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5515 if !every_lf_was_crlf || normalized != oid {
5516 return Ok(true);
5517 }
5518 } else if worktree.raw_oid != oid {
5519 return Ok(true);
5520 }
5521 }
5522 Ok(false)
5523}
5524
5525fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5526 Ok(index_entries(cwd)?
5527 .into_iter()
5528 .filter(|entry| entry.mode == "160000")
5529 .map(|entry| Gitlink {
5530 path: entry.path,
5531 oid: entry.oid,
5532 })
5533 .collect())
5534}
5535
5536fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5537 let mut tracked = BTreeMap::new();
5538 for entry in index_entries(cwd)? {
5539 if entry.mode == "160000" {
5540 continue;
5541 }
5542 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5543 tracked.insert(
5544 entry.path,
5545 TrackedEntry {
5546 index_mode: entry.mode,
5547 index_oid: entry.oid,
5548 worktree,
5549 },
5550 );
5551 }
5552 Ok(tracked)
5553}
5554
5555fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5556 let metadata = match std::fs::symlink_metadata(path) {
5557 Ok(metadata) => metadata,
5558 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5559 Err(e) => {
5560 return Err(spar_err!(
5561 "could not inspect tracked file {}: {e}",
5562 path.display()
5563 ))
5564 }
5565 };
5566 let mut fingerprint = Sha256::new();
5567 if metadata.file_type().is_symlink() {
5568 let target = std::fs::read_link(path)
5569 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5570 let bytes = os_str_bytes(target.as_os_str())?;
5571 fingerprint.update(b"symlink\0");
5572 fingerprint.update(&bytes);
5573 let content = Sha256::digest(&bytes).into();
5574 return Ok(Some(WorktreeFile {
5575 mode: "120000".to_string(),
5576 #[cfg(unix)]
5577 permissions: 0,
5578 raw_oid: git_blob_oid(oid_len, &bytes)?,
5579 fingerprint: fingerprint.finalize().into(),
5580 content,
5581 }));
5582 }
5583 if !metadata.is_file() {
5584 bail!("tracked path {} is not a file or symlink", path.display());
5585 }
5586
5587 let mut options = OpenOptions::new();
5588 options.read(true);
5589 #[cfg(unix)]
5590 {
5591 use std::os::unix::fs::OpenOptionsExt;
5592 options.custom_flags(libc::O_NOFOLLOW);
5593 }
5594 let mut file = options
5595 .open(path)
5596 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5597 let before = file
5598 .metadata()
5599 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5600 let mode = tracked_file_mode(&before);
5601 #[cfg(unix)]
5602 let permissions = {
5603 use std::os::unix::fs::MetadataExt;
5604 before.mode() & 0o7777
5605 };
5606 fingerprint.update(b"file\0");
5607 fingerprint.update(mode.as_bytes());
5608 #[cfg(unix)]
5609 fingerprint.update(permissions.to_le_bytes());
5610 fingerprint.update(before.len().to_le_bytes());
5611 let mut content = Sha256::new();
5612 let header = format!("blob {}\0", before.len());
5613 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5614 let mut buf = [0u8; 64 * 1024];
5615 loop {
5616 let read = file
5617 .read(&mut buf)
5618 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5619 if read == 0 {
5620 break;
5621 }
5622 fingerprint.update(&buf[..read]);
5623 content.update(&buf[..read]);
5624 object.update(&buf[..read]);
5625 }
5626 let after = file
5627 .metadata()
5628 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5629 if before.len() != after.len()
5630 || before.modified().ok() != after.modified().ok()
5631 || before.permissions() != after.permissions()
5632 {
5633 bail!(
5634 "tracked file {} changed while it was being inspected",
5635 path.display()
5636 );
5637 }
5638 let current = std::fs::symlink_metadata(path)
5639 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5640 if !same_file(&after, ¤t) {
5641 bail!(
5642 "tracked file {} was replaced while it was being inspected",
5643 path.display()
5644 );
5645 }
5646 Ok(Some(WorktreeFile {
5647 mode,
5648 #[cfg(unix)]
5649 permissions,
5650 raw_oid: object.finish(),
5651 fingerprint: fingerprint.finalize().into(),
5652 content: content.finalize().into(),
5653 }))
5654}
5655
5656fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5657 let metadata = std::fs::symlink_metadata(path)
5658 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5659 let mut digest = Sha256::new();
5660 if metadata.file_type().is_symlink() {
5661 digest.update(b"symlink\0");
5662 let target = std::fs::read_link(path)
5663 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5664 digest.update(os_str_bytes(target.as_os_str())?);
5665 return Ok(digest.finalize().into());
5666 }
5667 if !metadata.is_file() {
5668 bail!("attribute path {} is not a file or symlink", path.display());
5669 }
5670 let mut options = OpenOptions::new();
5671 options.read(true);
5672 #[cfg(unix)]
5673 {
5674 use std::os::unix::fs::OpenOptionsExt;
5675 options.custom_flags(libc::O_NOFOLLOW);
5676 }
5677 let mut file = options
5678 .open(path)
5679 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5680 let before = file
5681 .metadata()
5682 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5683 digest.update(b"file\0");
5684 let mut buf = [0u8; 64 * 1024];
5685 loop {
5686 let read = file
5687 .read(&mut buf)
5688 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5689 if read == 0 {
5690 break;
5691 }
5692 digest.update(&buf[..read]);
5693 }
5694 let after = file
5695 .metadata()
5696 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5697 let current = std::fs::symlink_metadata(path)
5698 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5699 if before.len() != after.len()
5700 || before.modified().ok() != after.modified().ok()
5701 || !same_file(&after, ¤t)
5702 {
5703 bail!(
5704 "attribute file {} changed while it was being inspected",
5705 path.display()
5706 );
5707 }
5708 Ok(digest.finalize().into())
5709}
5710
5711enum ObjectHasher {
5712 Sha1(Sha1),
5713 Sha256(Sha256),
5714}
5715
5716impl ObjectHasher {
5717 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5718 let mut hasher = match oid_len {
5719 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5720 64 => Self::Sha256(Sha256::new()),
5721 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5722 };
5723 hasher.update(header);
5724 Ok(hasher)
5725 }
5726
5727 fn update(&mut self, bytes: &[u8]) {
5728 match self {
5729 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5730 Self::Sha256(hasher) => hasher.update(bytes),
5731 }
5732 }
5733
5734 fn finish(self) -> String {
5735 let bytes = match self {
5736 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
5737 Self::Sha256(hasher) => hasher.finalize().to_vec(),
5738 };
5739 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
5740 }
5741}
5742
5743fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
5744 let header = format!("blob {}\0", bytes.len());
5745 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
5746 hasher.update(bytes);
5747 Ok(hasher.finish())
5748}
5749
5750fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
5751 let mut first = open_regular_file(path)?;
5752 let first_before = first
5753 .metadata()
5754 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5755 let mut raw_len = 0u64;
5756 let mut crlf_pairs = 0u64;
5757 let mut previous_was_cr = false;
5758 let mut every_lf_was_crlf = true;
5759 let mut buf = [0u8; 64 * 1024];
5760 loop {
5761 let read = first
5762 .read(&mut buf)
5763 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5764 if read == 0 {
5765 break;
5766 }
5767 raw_len = raw_len
5768 .checked_add(read as u64)
5769 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5770 for byte in &buf[..read] {
5771 if *byte == b'\n' {
5772 if previous_was_cr {
5773 crlf_pairs += 1;
5774 } else {
5775 every_lf_was_crlf = false;
5776 }
5777 }
5778 previous_was_cr = *byte == b'\r';
5779 }
5780 }
5781 let first_after = first
5782 .metadata()
5783 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5784 let current = std::fs::symlink_metadata(path)
5785 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5786 if raw_len != first_before.len()
5787 || !stable_file_metadata(&first_before, &first_after)
5788 || !stable_file_metadata(&first_after, ¤t)
5789 {
5790 bail!(
5791 "tracked file {} changed while line endings were inspected",
5792 path.display()
5793 );
5794 }
5795
5796 let normalized_len = raw_len
5797 .checked_sub(crlf_pairs)
5798 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5799 let header = format!("blob {normalized_len}\0");
5800 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5801 let mut second = open_regular_file(path)?;
5802 let second_before = second
5803 .metadata()
5804 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5805 if !stable_file_metadata(&first_after, &second_before) {
5806 bail!(
5807 "tracked file {} changed between line-ending checks",
5808 path.display()
5809 );
5810 }
5811 let mut pending_cr = false;
5812 loop {
5813 let read = second
5814 .read(&mut buf)
5815 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5816 if read == 0 {
5817 break;
5818 }
5819 for byte in &buf[..read] {
5820 if pending_cr {
5821 if *byte == b'\n' {
5822 object.update(b"\n");
5823 pending_cr = false;
5824 continue;
5825 }
5826 object.update(b"\r");
5827 pending_cr = false;
5828 }
5829 if *byte == b'\r' {
5830 pending_cr = true;
5831 } else {
5832 object.update(std::slice::from_ref(byte));
5833 }
5834 }
5835 }
5836 if pending_cr {
5837 object.update(b"\r");
5838 }
5839 let second_after = second
5840 .metadata()
5841 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5842 let current = std::fs::symlink_metadata(path)
5843 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5844 if !stable_file_metadata(&second_before, &second_after)
5845 || !stable_file_metadata(&second_after, ¤t)
5846 {
5847 bail!(
5848 "tracked file {} changed while line endings were hashed",
5849 path.display()
5850 );
5851 }
5852 Ok((object.finish(), every_lf_was_crlf))
5853}
5854
5855fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5856 let mut options = OpenOptions::new();
5857 options.read(true);
5858 #[cfg(unix)]
5859 {
5860 use std::os::unix::fs::OpenOptionsExt;
5861 options.custom_flags(libc::O_NOFOLLOW);
5862 }
5863 let file = options
5864 .open(path)
5865 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5866 let metadata = file
5867 .metadata()
5868 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5869 if !metadata.is_file() {
5870 bail!("tracked path {} is not a regular file", path.display());
5871 }
5872 Ok(file)
5873}
5874
5875fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5876 if !same_file(left, right)
5877 || left.len() != right.len()
5878 || left.modified().ok() != right.modified().ok()
5879 || left.permissions() != right.permissions()
5880 {
5881 return false;
5882 }
5883 #[cfg(unix)]
5884 {
5885 use std::os::unix::fs::MetadataExt;
5886 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5887 }
5888 #[cfg(not(unix))]
5889 {
5890 left.created().ok() == right.created().ok()
5891 }
5892}
5893
5894const ATTRIBUTE_BATCH: usize = 1000;
5901
5902fn check_attributes(
5903 cwd: &Path,
5904 paths: impl IntoIterator<Item = PathBuf>,
5905) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5906 let paths = paths.into_iter().collect::<BTreeSet<_>>();
5907 if paths.is_empty() {
5908 return Ok(BTreeMap::new());
5909 }
5910 let ordered = paths.into_iter().collect::<Vec<_>>();
5911 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5912 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
5913 values.extend(check_attribute_batch(cwd, batch)?);
5914 }
5915 Ok(values)
5916}
5917
5918fn check_attribute_batch(
5919 cwd: &Path,
5920 batch: &[PathBuf],
5921) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5922 const NAMES: [&str; 6] = [
5923 "filter",
5924 "working-tree-encoding",
5925 "ident",
5926 "text",
5927 "eol",
5928 "crlf",
5929 ];
5930 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
5931 let mut input = String::new();
5932 for path in &paths {
5933 let path = path.to_str().ok_or_else(|| {
5934 spar_err!(
5935 "cannot inspect attributes for a non-UTF-8 path in {}",
5936 cwd.display()
5937 )
5938 })?;
5939 input.push_str(path);
5940 input.push('\0');
5941 }
5942 let argv = git_without_automation_argv(&[
5943 "check-attr",
5944 "-z",
5945 "--cached",
5946 "--stdin",
5947 "filter",
5948 "working-tree-encoding",
5949 "ident",
5950 "text",
5951 "eol",
5952 "crlf",
5953 ]);
5954 let output = proc::run_bytes(
5955 &argv,
5956 &ExecOpts::new()
5957 .cwd(cwd)
5958 .timeout_secs(30)
5959 .stdin(input)
5960 .stop_descendants(true),
5961 )?;
5962 if !output.is_empty() && !output.ends_with(&[0]) {
5963 bail!(
5964 "git returned an unterminated attribute result for {}",
5965 cwd.display()
5966 );
5967 }
5968 let fields = output
5969 .split(|byte| *byte == 0)
5970 .filter(|field| !field.is_empty())
5971 .collect::<Vec<_>>();
5972 if fields.len() != paths.len() * NAMES.len() * 3 {
5973 bail!(
5974 "git returned an unexpected attribute result for {}",
5975 cwd.display()
5976 );
5977 }
5978 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5979 for record in fields.chunks_exact(3) {
5980 let path = safe_git_path(record[0], "attribute")?;
5981 if !paths.contains(&path) {
5982 bail!(
5983 "git returned attributes for the wrong path in {}",
5984 cwd.display()
5985 );
5986 }
5987 let name = std::str::from_utf8(record[1])
5988 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
5989 let value = std::str::from_utf8(record[2])
5990 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
5991 values
5992 .entry(path)
5993 .or_default()
5994 .insert(name.to_string(), value.to_string());
5995 }
5996 if paths.iter().any(|path| {
5997 values
5998 .get(path)
5999 .is_none_or(|attributes| attributes.len() != NAMES.len())
6000 }) {
6001 bail!(
6002 "git omitted an attribute result for a tracked path in {}",
6003 cwd.display()
6004 );
6005 }
6006 Ok(values)
6007}
6008
6009fn attribute_is_active(value: Option<&String>) -> bool {
6010 !matches!(
6011 value.map(String::as_str),
6012 None | Some("unspecified") | Some("unset")
6013 )
6014}
6015
6016fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
6017 attribute_is_active(values.get("filter"))
6018 || attribute_is_active(values.get("working-tree-encoding"))
6019}
6020
6021struct CheckoutConfig {
6030 autocrlf: Option<String>,
6031 eol: Option<String>,
6032 symlinks: Option<bool>,
6033}
6034
6035impl CheckoutConfig {
6036 fn read(cwd: &Path) -> Result<Self> {
6037 Ok(Self {
6038 autocrlf: git_config_value(cwd, "core.autocrlf")?,
6039 eol: git_config_value(cwd, "core.eol")?,
6040 symlinks: git_config_bool(cwd, "core.symlinks")?,
6041 })
6042 }
6043
6044 fn autocrlf_is_true(&self) -> bool {
6045 self.autocrlf.as_ref().is_some_and(|value| {
6046 matches!(
6047 value.to_ascii_lowercase().as_str(),
6048 "true" | "yes" | "on" | "1"
6049 )
6050 })
6051 }
6052
6053 fn eol_is(&self, wanted: &str) -> bool {
6054 self.eol
6055 .as_ref()
6056 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6057 }
6058}
6059
6060fn path_has_ambiguous_transform(
6061 config: &CheckoutConfig,
6062 values: &BTreeMap<String, String>,
6063) -> Result<bool> {
6064 if path_has_external_transform(values)
6065 || attribute_is_active(values.get("ident"))
6066 || attribute_is_active(values.get("crlf"))
6067 {
6068 return Ok(true);
6069 }
6070 let text = values.get("text").map(String::as_str);
6071 let eol = values.get("eol").map(String::as_str);
6072 if text == Some("auto") {
6073 return Ok(true);
6074 }
6075 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6076 || !matches!(
6077 eol,
6078 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6079 )
6080 {
6081 return Ok(true);
6082 }
6083 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6084 return Ok(config.autocrlf_is_true());
6085 }
6086 Ok(false)
6087}
6088
6089fn allows_expected_crlf(
6090 config: &CheckoutConfig,
6091 values: &BTreeMap<String, String>,
6092) -> Result<bool> {
6093 if path_has_external_transform(values)
6094 || attribute_is_active(values.get("ident"))
6095 || attribute_is_active(values.get("crlf"))
6096 {
6097 return Ok(false);
6098 }
6099 let text = values.get("text").map(String::as_str);
6100 let eol = values.get("eol").map(String::as_str);
6101 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6102 return Ok(false);
6103 }
6104 if eol == Some("crlf") {
6105 return Ok(true);
6106 }
6107 if text != Some("set") {
6108 return Ok(false);
6109 }
6110 if let Some(autocrlf) = config.autocrlf.as_deref() {
6111 match autocrlf.to_ascii_lowercase().as_str() {
6112 "true" | "yes" | "on" | "1" => return Ok(true),
6113 "input" => return Ok(false),
6114 _ => {}
6115 }
6116 }
6117 if config.eol_is("crlf") {
6118 return Ok(true);
6119 }
6120 #[cfg(windows)]
6121 if config.eol.is_none() || config.eol_is("native") {
6122 return Ok(true);
6123 }
6124 Ok(false)
6125}
6126
6127fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6128 let argv = git_without_automation_argv(&["config", "--get", key]);
6129 let output = proc::exec(
6130 &argv,
6131 &ExecOpts::new()
6132 .cwd(cwd)
6133 .timeout_secs(30)
6134 .check(false)
6135 .stop_descendants(true),
6136 )?;
6137 match output.code {
6138 0 => Ok(Some(output.stdout.trim().to_string())),
6139 1 => Ok(None),
6140 _ => bail!(
6141 "could not read Git configuration in {}: {}",
6142 cwd.display(),
6143 output.stderr.trim()
6144 ),
6145 }
6146}
6147
6148fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6149 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6150 let output = proc::exec(
6151 &argv,
6152 &ExecOpts::new()
6153 .cwd(cwd)
6154 .timeout_secs(30)
6155 .check(false)
6156 .stop_descendants(true),
6157 )?;
6158 match output.code {
6159 0 if output.stdout.trim() == "true" => Ok(Some(true)),
6160 0 if output.stdout.trim() == "false" => Ok(Some(false)),
6161 0 => bail!(
6162 "git returned an invalid boolean for {key} in {}",
6163 cwd.display()
6164 ),
6165 1 => Ok(None),
6166 _ => bail!(
6167 "could not read Git configuration in {}: {}",
6168 cwd.display(),
6169 output.stderr.trim()
6170 ),
6171 }
6172}
6173
6174#[cfg(unix)]
6175fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6176 use std::os::unix::fs::PermissionsExt;
6177 if metadata.permissions().mode() & 0o111 == 0 {
6178 "100644".to_string()
6179 } else {
6180 "100755".to_string()
6181 }
6182}
6183
6184#[cfg(not(unix))]
6185fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6186 "100644".to_string()
6187}
6188
6189fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6190 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6191 if !listed.is_empty() && !listed.ends_with(&[0]) {
6192 bail!(
6193 "git returned an unterminated tree listing for {}",
6194 cwd.display()
6195 );
6196 }
6197 let mut entries = Vec::new();
6198 for record in listed
6199 .split(|byte| *byte == 0)
6200 .filter(|record| !record.is_empty())
6201 {
6202 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6203 bail!("git returned a malformed tree record for {}", cwd.display());
6204 };
6205 let fields = record[..tab]
6206 .split(|byte| *byte == b' ')
6207 .collect::<Vec<_>>();
6208 if fields.len() != 3 {
6209 bail!("git returned a malformed tree header for {}", cwd.display());
6210 }
6211 let mode = std::str::from_utf8(fields[0])
6212 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6213 .to_string();
6214 let oid = std::str::from_utf8(fields[2])
6215 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6216 .to_string();
6217 entries.push(IndexEntry {
6218 path: safe_git_path(&record[tab + 1..], "tree")?,
6219 mode,
6220 oid,
6221 });
6222 }
6223 Ok(entries)
6224}
6225
6226fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6227 Ok(tree_entries(cwd, "HEAD")?
6228 .into_iter()
6229 .filter(|entry| entry.mode == "160000")
6230 .map(|entry| (entry.path, entry.oid))
6231 .collect())
6232}
6233
6234fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6235 let head = head_gitlinks(cwd)?;
6236 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6237 .into_iter()
6238 .map(|link| (link.path, link.oid))
6239 .collect();
6240 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6241 paths.extend(index.keys().cloned());
6242 Ok(paths
6243 .into_iter()
6244 .filter(|path| head.get(path) != index.get(path))
6245 .collect())
6246}
6247
6248fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6249 let path = parent.join(relative);
6250 let metadata = match std::fs::symlink_metadata(&path) {
6251 Ok(metadata) => metadata,
6252 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6253 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6254 };
6255 if !metadata.is_dir() {
6256 bail!("the gitlink at {} is not a directory", path.display());
6257 }
6258 let canonical = std::fs::canonicalize(&path)
6259 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6260 if canonical != path {
6261 bail!(
6262 "the gitlink at {} resolves through a symlink",
6263 path.display()
6264 );
6265 }
6266 if !path.join(".git").exists() {
6267 let empty = std::fs::read_dir(&path)
6268 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6269 .next()
6270 .is_none();
6271 if empty {
6272 return Ok(None);
6273 }
6274 bail!(
6275 "the uninitialized gitlink at {} contains local files",
6276 path.display()
6277 );
6278 }
6279 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6280 if inside.trim() != "true" {
6281 bail!("the gitlink at {} is not a worktree", path.display());
6282 }
6283 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6284 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6285 spar_err!(
6286 "could not resolve the gitlink top level at {}: {e}",
6287 path.display()
6288 )
6289 })?;
6290 if top != canonical {
6291 bail!(
6292 "the gitlink at {} belongs to a different worktree",
6293 path.display()
6294 );
6295 }
6296 Ok(Some(canonical))
6297}
6298
6299fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6313 let root = std::fs::canonicalize(cwd)
6314 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6315 let mut allowed = BTreeSet::from([root.join(".git")]);
6316 let mut checkouts = BTreeSet::new();
6317 let mut repositories = vec![root.clone()];
6318 let mut visited = BTreeSet::new();
6319 while let Some(repository) = repositories.pop() {
6320 let canonical = std::fs::canonicalize(&repository)
6321 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6322 if !visited.insert(canonical.clone()) {
6323 bail!("submodule recursion revisited {}", canonical.display());
6324 }
6325 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6326 let (path, nested) = untracked_record(&raw, "untracked")?;
6327 if nested {
6328 checkouts.insert(canonical.join(path));
6329 }
6330 }
6331 for link in gitlinks(&canonical)? {
6332 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6333 continue;
6334 };
6335 allowed.insert(submodule.join(".git"));
6336 repositories.push(submodule);
6337 }
6338 }
6339
6340 let scan_root = root.clone();
6344 let mut directories = vec![root];
6345 while let Some(directory) = directories.pop() {
6346 let entries = match std::fs::read_dir(&directory) {
6347 Ok(entries) => entries,
6348 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6349 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6350 };
6351 for entry in entries {
6352 let entry = match entry {
6353 Ok(entry) => entry,
6354 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6355 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6356 };
6357 let path = entry.path();
6358 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6359 continue;
6360 }
6361 if entry.file_name() == OsStr::new(".git") {
6362 if !allowed.contains(&path) {
6363 return Ok(Some(path));
6364 }
6365 continue;
6366 }
6367 let kind = match entry.file_type() {
6368 Ok(kind) => kind,
6369 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6370 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6371 };
6372 if kind.is_dir() && !checkouts.contains(&path) {
6373 directories.push(path);
6374 }
6375 }
6376 }
6377 Ok(None)
6378}
6379
6380pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6381 if let Some(path) = unexpected_nested_git_entry(cwd)? {
6382 bail!(
6383 "the worktree contains a Git entry at {} that Git does not report as a nested \
6384 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6385 prove they are unchanged.",
6386 path.display()
6387 );
6388 }
6389 let root = std::fs::canonicalize(cwd)
6390 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6391 let mut repositories = BTreeMap::new();
6392 let mut visited = BTreeSet::new();
6393 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
6394 Ok(GitState { repositories })
6395}
6396
6397fn collect_git_state(
6398 repository: &Path,
6399 prefix: &Path,
6400 visited: &mut BTreeSet<PathBuf>,
6401 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
6402) -> Result<()> {
6403 let canonical = std::fs::canonicalize(repository)
6404 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6405 if !visited.insert(canonical.clone()) {
6406 bail!("submodule recursion revisited {}", canonical.display());
6407 }
6408 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6409 let head = head.trim().to_string();
6410 if head.is_empty() {
6411 bail!("git returned an empty head for {}", repository.display());
6412 }
6413 let unsafe_index_flags = unsafe_index_flags(repository)?;
6414 let tracked = tracked_entries(repository)?;
6415 let gitlinks = gitlinks(repository)?;
6416 if repositories
6417 .insert(
6418 prefix.to_path_buf(),
6419 RepositoryState {
6420 head,
6421 unsafe_index_flags,
6422 tracked,
6423 gitlinks: gitlinks
6424 .iter()
6425 .map(|link| (link.path.clone(), link.oid.clone()))
6426 .collect(),
6427 },
6428 )
6429 .is_some()
6430 {
6431 bail!("Git state contains duplicate repository path {:?}", prefix);
6432 }
6433
6434 for link in gitlinks {
6435 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6436 continue;
6437 };
6438 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6439 }
6440 Ok(())
6441}
6442
6443fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6444 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6445 if !listed.is_empty() && !listed.ends_with(&[0]) {
6446 bail!(
6447 "git returned an unterminated index-flag listing for {}",
6448 cwd.display()
6449 );
6450 }
6451 let mut unsafe_records = Vec::new();
6452 for record in listed
6453 .split(|byte| *byte == 0)
6454 .filter(|record| !record.is_empty())
6455 {
6456 if record.len() < 3 || record[1] != b' ' {
6457 bail!(
6458 "git returned a malformed index-flag record for {}",
6459 cwd.display()
6460 );
6461 }
6462 if record[0] != b'H' {
6463 unsafe_records.extend_from_slice(record);
6464 unsafe_records.push(0);
6465 }
6466 }
6467 Ok(unsafe_records)
6468}
6469
6470pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6471 safe_git_state(cwd).map(|_| ())
6472}
6473
6474pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6475 let state = git_state(cwd)?;
6476 if let Some((path, _repository)) = state
6477 .repositories
6478 .iter()
6479 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6480 {
6481 let label = if path.as_os_str().is_empty() {
6482 cwd.to_path_buf()
6483 } else {
6484 cwd.join(path)
6485 };
6486 bail!(
6487 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6488 SPAR cannot prove the working files are unchanged, so it was kept.",
6489 label.display()
6490 );
6491 }
6492 Ok(state)
6493}
6494
6495fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6496 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
6497}
6498
6499fn repository_has_recoverable_work_with(
6500 cwd: &Path,
6501 include_ignored: bool,
6502 disposable: Disposable,
6503) -> Result<bool> {
6504 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6505 return Ok(true);
6506 }
6507 let mut visited = BTreeSet::new();
6508 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
6509}
6510
6511fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6512 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6513 let git_dir = PathBuf::from(git_dir.trim());
6514 let git_dir = if git_dir.is_absolute() {
6515 git_dir
6516 } else {
6517 cwd.join(git_dir)
6518 };
6519 let git_dir = std::fs::canonicalize(&git_dir)
6520 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6521 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6522 Ok(_) => return Ok(true),
6523 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6524 Err(error) => {
6525 return Err(spar_err!(
6526 "could not inspect per-worktree configuration in {}: {error}",
6527 git_dir.display()
6528 ))
6529 }
6530 }
6531
6532 let orig_head = git_dir.join("ORIG_HEAD");
6533 match std::fs::symlink_metadata(&orig_head) {
6534 Ok(metadata) if metadata.is_file() => {
6535 let oid = std::fs::read_to_string(&orig_head)
6536 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6537 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6538 return Ok(true);
6539 };
6540 if !commit_has_shared_ref(cwd, &commit)? {
6541 return Ok(true);
6542 }
6543 }
6544 Ok(_) => return Ok(true),
6545 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6546 Err(error) => {
6547 return Err(spar_err!(
6548 "could not inspect {}: {error}",
6549 orig_head.display()
6550 ))
6551 }
6552 }
6553
6554 let edit_message = git_dir.join("COMMIT_EDITMSG");
6555 match std::fs::symlink_metadata(&edit_message) {
6556 Ok(metadata) if metadata.is_file() => {
6557 let draft = std::fs::read(&edit_message)
6558 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6559 if draft != head_commit_message(cwd)? {
6560 return Ok(true);
6561 }
6562 }
6563 Ok(_) => return Ok(true),
6564 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6565 Err(error) => {
6566 return Err(spar_err!(
6567 "could not inspect {}: {error}",
6568 edit_message.display()
6569 ))
6570 }
6571 }
6572
6573 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6574 return Ok(true);
6575 }
6576
6577 let local_refs = run_git_bytes(
6578 cwd,
6579 &[
6580 "for-each-ref",
6581 "--format=%(refname)",
6582 "refs/worktree",
6583 "refs/bisect",
6584 "refs/rewritten",
6585 ],
6586 )?;
6587 if !local_refs.is_empty() {
6588 return Ok(true);
6589 }
6590
6591 for entry in std::fs::read_dir(&git_dir)
6592 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6593 {
6594 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6595 let known = matches!(
6596 entry.file_name().to_str(),
6597 Some(
6598 "HEAD"
6599 | "ORIG_HEAD"
6600 | "COMMIT_EDITMSG"
6601 | "commondir"
6602 | "gitdir"
6603 | "index"
6604 | "logs"
6605 | "refs"
6606 )
6607 );
6608 if !known {
6609 return Ok(true);
6610 }
6611 }
6612
6613 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6614 if !commit_has_shared_ref(cwd, head.trim())? {
6615 return Ok(true);
6616 }
6617 Ok(false)
6618}
6619
6620fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6621 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6622 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6623 bail!(
6624 "git returned a commit without a message separator in {}",
6625 cwd.display()
6626 );
6627 };
6628 Ok(commit[split + 2..].to_vec())
6629}
6630
6631fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6632 let metadata = match std::fs::symlink_metadata(logs) {
6633 Ok(metadata) => metadata,
6634 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6635 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6636 };
6637 if !metadata.is_dir() {
6638 return Ok(true);
6639 }
6640 let mut files = Vec::new();
6641 let mut directories = vec![logs.to_path_buf()];
6642 while let Some(directory) = directories.pop() {
6643 for entry in std::fs::read_dir(&directory)
6644 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6645 {
6646 let entry =
6647 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6648 let path = entry.path();
6649 let kind = entry
6650 .file_type()
6651 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6652 if kind.is_dir() {
6653 directories.push(path);
6654 } else if kind.is_file() {
6655 files.push(path);
6656 } else {
6657 return Ok(true);
6658 }
6659 }
6660 }
6661
6662 let mut commits = BTreeSet::new();
6663 for path in files {
6664 if !collect_reflog_commits(cwd, &path, &mut commits)? {
6665 return Ok(true);
6666 }
6667 }
6668 for commit in commits {
6669 if !commit_has_shared_ref(cwd, &commit)? {
6670 return Ok(true);
6671 }
6672 }
6673 Ok(false)
6674}
6675
6676fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6680 let common = common_git_dir(cwd)?;
6681 let reflog = common.join("logs").join(refname);
6682 let metadata = match std::fs::symlink_metadata(&reflog) {
6683 Ok(metadata) => metadata,
6684 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6685 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6686 };
6687 if !metadata.is_file() {
6688 return Ok(false);
6689 }
6690 let mut commits = BTreeSet::new();
6691 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6692 return Ok(false);
6693 }
6694 for commit in commits {
6695 if is_ancestor(cwd, &commit, durable_tip)?
6696 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6697 {
6698 continue;
6699 }
6700 return Ok(false);
6701 }
6702 Ok(true)
6703}
6704
6705fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6706 let file = std::fs::File::open(path)
6707 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6708 for line in std::io::BufReader::new(file).lines() {
6709 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6710 let mut fields = line.splitn(3, ' ');
6711 let Some(old) = fields.next() else {
6712 return Ok(false);
6713 };
6714 let Some(new) = fields.next() else {
6715 return Ok(false);
6716 };
6717 if fields.next().is_none() {
6718 return Ok(false);
6719 }
6720 for oid in [old, new] {
6721 if oid.bytes().all(|byte| byte == b'0') {
6722 continue;
6723 }
6724 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6725 return Ok(false);
6726 };
6727 commits.insert(commit);
6728 }
6729 }
6730 Ok(true)
6731}
6732
6733fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6734 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6735 let path = PathBuf::from(raw.trim());
6736 let path = if path.is_absolute() {
6737 path
6738 } else {
6739 cwd.join(path)
6740 };
6741 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6742}
6743
6744fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6745 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6746 let output = proc::exec(
6747 &argv,
6748 &ExecOpts::new()
6749 .cwd(cwd)
6750 .timeout_secs(30)
6751 .check(false)
6752 .stop_descendants(true),
6753 )?;
6754 match output.code {
6755 0 => Ok(true),
6756 1 => Ok(false),
6757 _ => bail!("{}", proc::failure_message(&argv, &output)),
6758 }
6759}
6760
6761fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6762 let commit = format!("{oid}^{{commit}}");
6763 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6764 let output = proc::exec(
6765 &argv,
6766 &ExecOpts::new()
6767 .cwd(cwd)
6768 .timeout_secs(30)
6769 .check(false)
6770 .stop_descendants(true),
6771 )?;
6772 if output.code != 0 {
6773 return Ok(None);
6774 }
6775 let oid = output.stdout.trim();
6776 if oid.is_empty() {
6777 return Ok(None);
6778 }
6779 Ok(Some(oid.to_string()))
6780}
6781
6782fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6783 commit_has_shared_ref_except(cwd, oid, None)
6784}
6785
6786fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6787 let contains = format!("--contains={oid}");
6788 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6789 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6790 !record.is_empty()
6791 && !record.starts_with(b"refs/worktree/")
6792 && !record.starts_with(b"refs/bisect/")
6793 && !record.starts_with(b"refs/rewritten/")
6794 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6795 }))
6796}
6797
6798fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
6813 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6814 for raw in &ordinary {
6815 let (path, _) = untracked_record(raw, "untracked")?;
6816 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
6817 continue;
6818 }
6819 return Ok(true);
6820 }
6821 let listed = untracked_listing(
6822 cwd,
6823 &[
6824 "ls-files",
6825 "--others",
6826 "--ignored",
6827 "--exclude-standard",
6828 "-z",
6829 ],
6830 )?;
6831 for raw in listed {
6832 let (path, nested) = untracked_record(&raw, "ignored")?;
6833 if nested {
6837 return Ok(true);
6838 }
6839 if disposable == Disposable::BuildOutput && !is_generated_artifact(&path) {
6840 return Ok(true);
6841 }
6842 }
6843 Ok(false)
6844}
6845
6846fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6847 let listed = run_git_bytes(cwd, args)?;
6848 if !listed.is_empty() && !listed.ends_with(&[0]) {
6849 bail!(
6850 "git returned an unterminated untracked-file list for {}",
6851 cwd.display()
6852 );
6853 }
6854 Ok(listed
6855 .split(|byte| *byte == 0)
6856 .filter(|raw| !raw.is_empty())
6857 .map(|raw| raw.to_vec())
6858 .collect())
6859}
6860
6861fn repository_has_recoverable_work_inner(
6862 cwd: &Path,
6863 include_ignored: bool,
6864 disposable: Disposable,
6865 visited: &mut BTreeSet<PathBuf>,
6866) -> Result<bool> {
6867 let canonical = std::fs::canonicalize(cwd)
6868 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6869 if !visited.insert(canonical.clone()) {
6870 bail!("submodule recursion revisited {}", canonical.display());
6871 }
6872 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
6873 return Ok(true);
6874 }
6875 if !unsafe_index_flags(cwd)?.is_empty() {
6876 return Ok(true);
6877 }
6878 if attributes_may_be_modified(cwd)? {
6879 return Ok(true);
6880 }
6881 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6882 return Ok(true);
6883 }
6884 if include_ignored {
6885 let index = index_entries(cwd)?
6886 .into_iter()
6887 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6888 .collect::<BTreeMap<_, _>>();
6889 let head = tree_entries(cwd, "HEAD")?
6890 .into_iter()
6891 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6892 .collect::<BTreeMap<_, _>>();
6893 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6894 return Ok(true);
6895 }
6896 let tracked = tracked_entries(cwd)?;
6897 let effective = check_attributes(cwd, tracked.keys().cloned())?;
6898 let config = CheckoutConfig::read(cwd)?;
6899 for (path, entry) in tracked {
6900 let Some(worktree) = entry.worktree else {
6901 return Ok(true);
6902 };
6903 let attributes = effective.get(&path).ok_or_else(|| {
6904 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6905 })?;
6906 if path_has_ambiguous_transform(&config, attributes)? {
6907 return Ok(true);
6908 }
6909 let symlink_file = entry.index_mode == "120000"
6910 && worktree.mode == "100644"
6911 && worktree.raw_oid == entry.index_oid
6912 && config.symlinks == Some(false);
6913 if worktree.mode != entry.index_mode && !symlink_file {
6914 return Ok(true);
6915 }
6916 if entry.index_mode == "120000" {
6917 if worktree.raw_oid != entry.index_oid {
6918 return Ok(true);
6919 }
6920 continue;
6921 }
6922 #[cfg(unix)]
6923 {
6924 let expected = if entry.index_mode == "100755" {
6925 0o755
6926 } else {
6927 0o644
6928 };
6929 if worktree.permissions != expected {
6930 return Ok(true);
6931 }
6932 }
6933 if allows_expected_crlf(&config, attributes)? {
6934 let (normalized, every_lf_was_crlf) =
6935 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6936 if !every_lf_was_crlf || normalized != entry.index_oid {
6937 return Ok(true);
6938 }
6939 } else if worktree.raw_oid != entry.index_oid {
6940 return Ok(true);
6941 }
6942 }
6943 } else {
6944 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6945 if !run_git_bytes(cwd, &args)?.is_empty() {
6946 return Ok(true);
6947 }
6948 }
6949 for link in gitlinks(cwd)? {
6950 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6951 continue;
6952 };
6953 if include_ignored {
6958 return Ok(true);
6959 }
6960 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6961 if head.trim() != link.oid {
6962 return Ok(true);
6963 }
6964 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
6965 {
6966 return Ok(true);
6967 }
6968 }
6969 Ok(false)
6970}
6971
6972pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6973 repository_has_recoverable_work(cwd, false)
6974}
6975
6976fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6977 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6978 Ok(!run_git_bytes(cwd, &args)?.is_empty())
6979}
6980
6981#[cfg(unix)]
6982fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6983 use std::os::unix::ffi::OsStringExt;
6984 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6985}
6986
6987#[cfg(not(unix))]
6988fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6989 String::from_utf8(raw.to_vec())
6990 .map(PathBuf::from)
6991 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6992}
6993
6994fn vanished_untracked_file() -> UntrackedFile {
7010 UntrackedFile {
7011 kind: 0,
7012 len: 0,
7013 modified: None,
7014 created: None,
7015 readonly: false,
7016 symlink_target: None,
7017 #[cfg(unix)]
7018 device: 0,
7019 #[cfg(unix)]
7020 inode: 0,
7021 #[cfg(unix)]
7022 mode: 0,
7023 #[cfg(unix)]
7024 change_seconds: 0,
7025 #[cfg(unix)]
7026 change_nanoseconds: 0,
7027 }
7028}
7029
7030fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
7031 let metadata = match std::fs::symlink_metadata(path) {
7032 Ok(metadata) => metadata,
7033 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7034 Err(e) => {
7035 return Err(spar_err!(
7036 "could not inspect the nested repository at {}: {e}",
7037 path.display()
7038 ))
7039 }
7040 };
7041 if !metadata.is_dir() {
7042 bail!(
7043 "git reported {} as a nested repository, but it is not a directory",
7044 path.display()
7045 );
7046 }
7047 if std::fs::symlink_metadata(path.join(".git")).is_err() {
7048 bail!(
7049 "git reported {} as a nested repository, but it has no Git entry",
7050 path.display()
7051 );
7052 }
7053 #[cfg(unix)]
7054 {
7055 use std::os::unix::fs::MetadataExt;
7056 Ok(UntrackedFile {
7057 kind: 3,
7058 len: 0,
7059 modified: None,
7060 created: metadata.created().ok(),
7061 readonly: metadata.permissions().readonly(),
7062 symlink_target: None,
7063 device: metadata.dev(),
7064 inode: metadata.ino(),
7065 mode: metadata.mode(),
7066 change_seconds: 0,
7067 change_nanoseconds: 0,
7068 })
7069 }
7070 #[cfg(not(unix))]
7071 {
7072 Ok(UntrackedFile {
7073 kind: 3,
7074 len: 0,
7075 modified: None,
7076 created: metadata.created().ok(),
7077 readonly: metadata.permissions().readonly(),
7078 symlink_target: None,
7079 })
7080 }
7081}
7082
7083fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7084 let metadata = match std::fs::symlink_metadata(path) {
7085 Ok(metadata) => metadata,
7086 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7087 Err(e) => {
7088 return Err(spar_err!(
7089 "could not inspect untracked file {}: {e}",
7090 path.display()
7091 ))
7092 }
7093 };
7094 let kind = if metadata.file_type().is_symlink() {
7095 2
7096 } else if metadata.is_file() {
7097 1
7098 } else {
7099 bail!(
7100 "untracked path {} is not a regular file or symlink",
7101 path.display()
7102 );
7103 };
7104 let symlink_target = if kind == 2 {
7105 match std::fs::read_link(path) {
7106 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7107 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7108 return Ok(vanished_untracked_file())
7109 }
7110 Err(e) => {
7111 return Err(spar_err!(
7112 "could not read untracked symlink {}: {e}",
7113 path.display()
7114 ))
7115 }
7116 }
7117 } else {
7118 None
7119 };
7120 #[cfg(unix)]
7121 {
7122 use std::os::unix::fs::MetadataExt;
7123 Ok(UntrackedFile {
7124 kind,
7125 len: metadata.len(),
7126 modified: metadata.modified().ok(),
7127 created: metadata.created().ok(),
7128 readonly: metadata.permissions().readonly(),
7129 symlink_target,
7130 device: metadata.dev(),
7131 inode: metadata.ino(),
7132 mode: metadata.mode(),
7133 change_seconds: metadata.ctime(),
7134 change_nanoseconds: metadata.ctime_nsec(),
7135 })
7136 }
7137 #[cfg(not(unix))]
7138 {
7139 Ok(UntrackedFile {
7140 kind,
7141 len: metadata.len(),
7142 modified: metadata.modified().ok(),
7143 created: metadata.created().ok(),
7144 readonly: metadata.permissions().readonly(),
7145 symlink_target,
7146 })
7147 }
7148}
7149
7150#[cfg(unix)]
7151fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7152 use std::os::unix::ffi::OsStrExt;
7153 Ok(value.as_bytes().to_vec())
7154}
7155
7156#[cfg(not(unix))]
7157fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7158 value
7159 .to_str()
7160 .map(|value| value.as_bytes().to_vec())
7161 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7162}
7163
7164#[cfg(unix)]
7165fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7166 use std::os::unix::fs::MetadataExt;
7167 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7168}
7169
7170#[cfg(not(unix))]
7171fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7172 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7173}
7174
7175#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7176pub struct BranchRecord {
7177 pub kind: String,
7178 pub number: i64,
7179}
7180
7181pub fn review_ref(number: i64) -> String {
7184 format!("refs/spar/pr-{number}")
7185}
7186
7187pub fn is_finished(state: &str) -> bool {
7188 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7189}
7190
7191pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7198 if let Some(parent) = path.parent() {
7199 std::fs::create_dir_all(parent)
7200 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7201 }
7202 let tmp = path.with_extension(format!(
7205 "{}.tmp",
7206 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7207 ));
7208 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7209 std::fs::rename(&tmp, path)
7210 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7211 Ok(())
7212}
7213
7214pub fn has_heading(text: &str, title: &str) -> bool {
7221 let wanted = title.trim().to_lowercase();
7222 if wanted.is_empty() {
7223 return false;
7224 }
7225 text.lines()
7226 .filter_map(|line| line.trim().strip_prefix("## "))
7227 .any(|heading| heading.trim().to_lowercase() == wanted)
7228}
7229
7230pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7233 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7234}
7235
7236#[derive(Deserialize)]
7238#[serde(rename_all = "camelCase")]
7239pub struct LinkedPrRow {
7240 number: i64,
7241 #[serde(default)]
7242 url: String,
7243 #[serde(default)]
7244 title: String,
7245 #[serde(default)]
7246 closing_issues_references: Vec<IssueRef>,
7247}
7248
7249pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7256 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7257}
7258
7259pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7261 rows.iter()
7262 .find(|row| {
7263 row.closing_issues_references
7264 .iter()
7265 .any(|linked| linked.number == issue)
7266 })
7267 .map(|row| PrRef {
7268 number: row.number,
7269 url: row.url.clone(),
7270 title: row.title.clone(),
7271 })
7272}
7273
7274pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7276 pick_linked_pr(&linked_pr_rows(json)?, issue)
7277}
7278
7279fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7286 if text.trim().is_empty() {
7287 return Err(spar_err!("GitHub returned no comment data"));
7288 }
7289 let mut out = Vec::new();
7290 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7291 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7292 Value::Array(items) => out.extend(items),
7293 _ => return Err(spar_err!("unexpected non-array comment page")),
7294 }
7295 }
7296 Ok(out)
7297}
7298
7299pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7300 let mut out = Vec::new();
7301 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7302 match value {
7303 Ok(Value::Array(items)) => out.extend(items),
7304 Ok(other) => out.push(other),
7305 Err(_) => break,
7306 }
7307 }
7308 out
7309}
7310
7311pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7319 body.trim_start().starts_with(STATE_MARKER)
7320 && !viewer.trim().is_empty()
7321 && author.trim().eq_ignore_ascii_case(viewer.trim())
7322 && parse_state_comment(body).is_some()
7323}
7324
7325pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7328 let marker = body.find(STATE_MARKER)?;
7329 let start = body[marker..].find('{')? + marker;
7330 let end = body.rfind('}')?;
7331 if end <= start {
7332 return None;
7333 }
7334 match serde_json::from_str(&body[start..=end]) {
7335 Ok(state) => Some(state),
7336 Err(_) => {
7337 logdim!("found a spar state comment but could not parse it");
7338 None
7339 }
7340 }
7341}
7342
7343fn choose_state_for_head(
7344 candidates: Vec<PersistedState>,
7345 actual_head: &str,
7346) -> Option<PersistedState> {
7347 let matching: Vec<PersistedState> = candidates
7348 .iter()
7349 .filter(|state| state.pr_head == actual_head)
7350 .cloned()
7351 .collect();
7352 if !matching.is_empty() {
7353 return newest_state(matching);
7354 }
7355 newest_state(candidates)
7356}
7357
7358fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7359 candidates.into_iter().reduce(|best, candidate| {
7360 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7361 candidate
7362 } else {
7363 best
7368 }
7369 })
7370}
7371
7372pub fn self_binary() -> Result<PathBuf> {
7378 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7379 let path = PathBuf::from(path);
7380 if proc::is_executable(&path) {
7381 return Ok(path);
7382 }
7383 bail!(
7384 "SPAR_SELF_BIN is set to {}, which is not executable",
7385 path.display()
7386 );
7387 }
7388 std::env::current_exe()
7389 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
7390}
7391
7392fn bool_env(value: bool) -> &'static str {
7393 if value {
7394 "1"
7395 } else {
7396 "0"
7397 }
7398}
7399
7400pub fn sh_quote(text: &str) -> String {
7403 format!("'{}'", text.replace('\'', r"'\''"))
7404}
7405
7406pub fn style_from_env() -> Style {
7409 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
7410 Style {
7411 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
7412 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
7413 ..Style::permissive()
7414 }
7415}
7416
7417#[cfg(test)]
7418mod tests {
7419 use super::*;
7420 use crate::config::StateStore;
7421 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
7422 use std::process::Command;
7423
7424 fn repo_for_titles() -> Repo {
7425 Repo {
7426 root: PathBuf::from("/nonexistent"),
7427 style: Style::default(),
7428 branch_prefix: String::new(),
7429 state_store: StateStore::Local,
7430 followups: crate::config::Followups::Issues,
7431 drafts: Drafts::Never,
7432 viewer: OnceLock::new(),
7433 checkpoints: Mutex::new(BTreeMap::new()),
7434 writes: WriteStats::default(),
7435 }
7436 }
7437
7438 #[test]
7439 fn write_results_accumulate_for_the_run() {
7440 let repo = repo_for_titles();
7441
7442 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
7443 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
7444
7445 assert_eq!(
7446 WriteSummary {
7447 attempted: 2,
7448 failed: 1,
7449 },
7450 repo.write_summary()
7451 );
7452 }
7453
7454 #[test]
7455 fn only_failed_write_preflights_join_the_summary() {
7456 let repo = repo_for_titles();
7457
7458 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
7459 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
7460
7461 assert_eq!(
7462 WriteSummary {
7463 attempted: 1,
7464 failed: 1,
7465 },
7466 repo.write_summary()
7467 );
7468 }
7469
7470 #[test]
7471 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
7472 let repo = repo_for_titles();
7473
7474 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
7475 assert_eq!(
7476 WriteSummary {
7477 attempted: 1,
7478 failed: 1,
7479 },
7480 repo.write_summary()
7481 );
7482 }
7483
7484 #[test]
7485 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7486 let mut repo = repo_for_titles();
7487 repo.followups = Followups::Local;
7488
7489 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7490 assert_eq!(WriteSummary::default(), repo.write_summary());
7491 }
7492
7493 #[test]
7494 fn a_failed_remote_state_read_stops_before_state_mutation() {
7495 let root = std::env::temp_dir().join(format!(
7496 "spar-state-preflight-{}-{}",
7497 std::process::id(),
7498 std::time::SystemTime::now()
7499 .duration_since(std::time::UNIX_EPOCH)
7500 .unwrap()
7501 .as_nanos()
7502 ));
7503 std::fs::create_dir_all(&root).unwrap();
7504 let _fixture = ReviewFixture { root: root.clone() };
7505 let mut repo = repo_for_titles();
7506 repo.root = root;
7507 repo.state_store = StateStore::Both;
7508 let state = PersistedState {
7509 version: 1,
7510 checkpoint: 4,
7511 round: 2,
7512 next_actor: "a".into(),
7513 status: Status::Pending,
7514 pr_head: "abc123".into(),
7515 ledger: Ledger::new(),
7516 filed: Vec::new(),
7517 open_findings: Vec::new(),
7518 disputes: Vec::new(),
7519 noted: Vec::new(),
7520 };
7521
7522 let error = repo
7523 .write_state_after_remote_read(
7524 7,
7525 &state,
7526 Err(crate::error::SparError::new("state comments unavailable")),
7527 )
7528 .unwrap_err();
7529
7530 assert!(error.to_string().contains("state comments unavailable"));
7531 assert!(!repo.state_path(7).exists());
7532 assert_eq!(0, repo.remembered_checkpoint(7));
7533 assert_eq!(
7534 WriteSummary {
7535 attempted: 1,
7536 failed: 1,
7537 },
7538 repo.write_summary()
7539 );
7540 }
7541
7542 #[test]
7543 fn only_known_build_and_cache_directories_are_generated_artifacts() {
7544 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7545 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7546 assert!(is_generated_artifact(Path::new(
7547 "package/node_modules/dependency/file.js"
7548 )));
7549 assert!(!is_generated_artifact(Path::new(
7550 "distribution/required-package.js"
7551 )));
7552 assert!(!is_generated_artifact(Path::new(
7553 "generated/required-fixture.txt"
7554 )));
7555 assert!(!is_generated_artifact(Path::new("local.env")));
7556 }
7557
7558 struct ReviewFixture {
7559 root: PathBuf,
7560 }
7561
7562 impl Drop for ReviewFixture {
7563 fn drop(&mut self) {
7564 let _ = std::fs::remove_dir_all(&self.root);
7565 }
7566 }
7567
7568 fn test_git(cwd: &Path, args: &[&str]) -> String {
7569 let output = Command::new("git")
7570 .args(args)
7571 .current_dir(cwd)
7572 .output()
7573 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7574 assert!(
7575 output.status.success(),
7576 "git {args:?} failed: {}",
7577 String::from_utf8_lossy(&output.stderr)
7578 );
7579 String::from_utf8_lossy(&output.stdout).into_owned()
7580 }
7581
7582 fn review_fixture(
7583 tag: &str,
7584 number: i64,
7585 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7586 use std::sync::atomic::{AtomicU32, Ordering};
7587 static NEXT: AtomicU32 = AtomicU32::new(0);
7588 let id = NEXT.fetch_add(1, Ordering::Relaxed);
7589 let root =
7590 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7591 let origin = root.join("origin.git");
7592 let work = root.join("work");
7593 std::fs::create_dir_all(&origin).unwrap();
7594 std::fs::create_dir_all(&work).unwrap();
7595 test_git(&origin, &["init", "--bare", "-b", "main"]);
7596 test_git(&work, &["init", "-b", "main"]);
7597 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7598 test_git(&work, &["config", "user.name", "spar test"]);
7599 test_git(&work, &["config", "commit.gpgsign", "false"]);
7600 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7601 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7602 std::fs::write(work.join("README.md"), "seed\n").unwrap();
7603 std::fs::write(work.join("data.txt"), "old\n").unwrap();
7604 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7605 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7606 test_git(&work, &["add", "."]);
7607 test_git(&work, &["commit", "-m", "seed"]);
7608 test_git(
7609 &work,
7610 &["remote", "add", "origin", origin.to_str().unwrap()],
7611 );
7612 test_git(&work, &["push", "-u", "origin", "main"]);
7613 test_git(
7614 &work,
7615 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7616 );
7617 let cfg = crate::config::parse(
7618 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7619 )
7620 .unwrap();
7621 let repo = Repo::open(&work, &cfg).unwrap();
7622 let path = repo.worktree_for_pr_head(number).unwrap();
7623 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7624 (ReviewFixture { root }, repo, path, checkpoint)
7625 }
7626
7627 #[test]
7628 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7629 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7630
7631 repo.release_review_worktree_checked(901, &checkpoint)
7632 .unwrap();
7633
7634 assert!(!path.exists());
7635 }
7636
7637 #[test]
7638 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7639 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7640 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7641 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7642 test_git(&path, &["add", "recovery.txt"]);
7643 test_git(&path, &["commit", "-m", "recovery commit"]);
7644 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7645 test_git(&path, &["reset", "--hard", "main"]);
7646
7647 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7648 test_git(
7649 &path,
7650 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7651 );
7652 }
7653
7654 #[test]
7655 fn a_review_ref_reflog_only_commit_prevents_deletion() {
7656 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7657 let local_ref = review_ref(921);
7658 let original = test_git(&path, &["rev-parse", &local_ref]);
7659 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7660 let recovery = test_git(
7661 &path,
7662 &[
7663 "commit-tree",
7664 tree.trim(),
7665 "-p",
7666 original.trim(),
7667 "-m",
7668 "review ref recovery",
7669 ],
7670 );
7671 test_git(
7672 &path,
7673 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7674 );
7675 test_git(
7676 &path,
7677 &["update-ref", &local_ref, original.trim(), recovery.trim()],
7678 );
7679
7680 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7681 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7682 test_git(
7683 &path,
7684 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7685 );
7686 }
7687
7688 #[test]
7689 fn an_unpublished_commit_message_draft_is_recoverable() {
7690 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7691 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7692 let git_dir = if raw.is_absolute() {
7693 raw
7694 } else {
7695 path.join(raw)
7696 };
7697 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7698
7699 assert!(repository_has_recoverable_work(&path, true).unwrap());
7700 assert_eq!(
7701 "unique recovery draft\n",
7702 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7703 );
7704 }
7705
7706 #[test]
7707 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7708 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7709 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7710
7711 let error = repo
7712 .release_review_worktree_checked(902, &checkpoint)
7713 .unwrap_err();
7714
7715 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7716 assert!(error.to_string().contains("kept for recovery"), "{error}");
7717 assert_eq!(
7718 "recover me\n",
7719 std::fs::read_to_string(path.join("README.md")).unwrap()
7720 );
7721 repo.release_review_worktree(902);
7722 }
7723
7724 #[test]
7725 fn a_review_commit_is_retained_after_a_checked_read() {
7726 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7727 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7728 test_git(&path, &["add", "review-note.txt"]);
7729 test_git(&path, &["commit", "-m", "local review recovery"]);
7730 let head = test_git(&path, &["rev-parse", "HEAD"]);
7731
7732 let error = repo
7733 .release_review_worktree_checked(903, &checkpoint)
7734 .unwrap_err();
7735
7736 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7737 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7738 assert_eq!(
7739 "recover me\n",
7740 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7741 );
7742 repo.release_review_worktree(903);
7743 }
7744
7745 #[test]
7749 fn an_ignored_review_file_is_retained_after_a_checked_read() {
7750 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7751 std::fs::create_dir_all(path.join("generated")).unwrap();
7752 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7753
7754 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7755 .expect("its own output is not a change");
7756 let error = repo
7757 .release_review_worktree_checked(904, &checkpoint)
7758 .unwrap_err();
7759
7760 assert!(error.to_string().contains("recoverable"), "{error}");
7761 assert_eq!(
7762 "recover me\n",
7763 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7764 );
7765 repo.release_review_worktree(904);
7766 }
7767
7768 #[test]
7769 fn a_preexisting_ignored_review_file_change_is_retained() {
7775 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7776 std::fs::create_dir_all(path.join("generated")).unwrap();
7777 let ignored = path.join("generated/recovery.txt");
7778 std::fs::write(&ignored, "before\n").unwrap();
7779 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7780 std::fs::write(&ignored, "after!\n").unwrap();
7781
7782 let error = repo
7783 .release_review_worktree_checked(905, &checkpoint)
7784 .unwrap_err();
7785
7786 assert!(error.to_string().contains("recoverable"), "{error}");
7787 assert!(path.exists());
7788 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7789 repo.release_review_worktree(905);
7790 }
7791
7792 #[test]
7793 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7794 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7795 std::fs::create_dir_all(path.join("generated")).unwrap();
7796 let ignored = path.join("generated/recovery.txt");
7797 std::fs::write(&ignored, "keep me\n").unwrap();
7798 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7799
7800 let error = repo
7801 .release_review_worktree_checked(906, &checkpoint)
7802 .unwrap_err();
7803
7804 assert!(error.to_string().contains("recoverable"), "{error}");
7805 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7806 }
7807
7808 #[test]
7809 fn overwriting_a_preexisting_untracked_file_is_detected() {
7810 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7811 let untracked = path.join("notes.txt");
7812 std::fs::write(&untracked, "before\n").unwrap();
7813 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7814 std::fs::write(&untracked, "after!\n").unwrap();
7815
7816 let error = repo
7817 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7818 .unwrap_err();
7819
7820 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7821 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7822 }
7823
7824 #[test]
7825 fn an_assume_unchanged_edit_is_detected() {
7826 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7827 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7828 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7829
7830 let error = repo
7831 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7832 .unwrap_err();
7833
7834 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7835 assert_eq!(
7836 "hidden\n",
7837 std::fs::read_to_string(path.join("README.md")).unwrap()
7838 );
7839 }
7840
7841 #[test]
7842 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7843 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7844 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7845 test_git(&path, &["add", "README.md"]);
7846 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7847
7848 let error = repo
7849 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7850 .unwrap_err();
7851
7852 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7853 assert_eq!(
7854 b"seed\r\n",
7855 std::fs::read(path.join("README.md")).unwrap().as_slice()
7856 );
7857 }
7858
7859 #[cfg(unix)]
7860 #[test]
7861 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7862 use std::os::unix::fs::PermissionsExt;
7863
7864 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7865 test_git(&path, &["config", "core.filemode", "false"]);
7866 let readme = path.join("README.md");
7867 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7868 permissions.set_mode(0o755);
7869 std::fs::set_permissions(&readme, permissions).unwrap();
7870 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7871
7872 let error = repo
7873 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7874 .unwrap_err();
7875
7876 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7877 assert_eq!(
7878 0o755,
7879 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7880 );
7881 }
7882
7883 #[test]
7884 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7885 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7886 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7887 test_git(&path, &["add", ".gitattributes"]);
7888 test_git(&path, &["commit", "-m", "select data filter"]);
7889 let baseline = repo.worktree_baseline(&path).unwrap();
7890 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7891
7892 assert!(repo
7893 .commit_pending_changes(&path, &baseline, "change data", "change data")
7894 .unwrap());
7895 let error = repo
7896 .refuse_unrepresented_tracked_changes(&path, &baseline)
7897 .unwrap_err();
7898
7899 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7900 assert_eq!(
7901 "secret: recover me\nnew\n",
7902 std::fs::read_to_string(path.join("data.txt")).unwrap()
7903 );
7904 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7905 }
7906
7907 #[test]
7908 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7909 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7910 std::fs::create_dir_all(path.join("target")).unwrap();
7911 let untracked = path.join("target/user.yaml");
7912 std::fs::write(&untracked, "user data\n").unwrap();
7913 let baseline = repo.worktree_baseline(&path).unwrap();
7914 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7915
7916 assert!(repo
7917 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7918 .unwrap());
7919
7920 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
7921 assert_eq!(
7922 "?? target/user.yaml\n",
7923 test_git(&path, &["status", "--short", "--untracked-files=all"])
7924 );
7925 assert!(test_git(
7926 &path,
7927 &[
7928 "ls-tree",
7929 "-r",
7930 "--name-only",
7931 "HEAD",
7932 "--",
7933 "target/user.yaml"
7934 ]
7935 )
7936 .is_empty());
7937 }
7938
7939 #[test]
7940 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
7941 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
7942 std::fs::create_dir_all(path.join("target")).unwrap();
7943 let untracked = path.join("target/user.yaml");
7944 std::fs::write(&untracked, "before\n").unwrap();
7945 let baseline = repo.worktree_baseline(&path).unwrap();
7946 let before = test_git(&path, &["rev-parse", "HEAD"]);
7947 std::fs::write(&untracked, "after\n").unwrap();
7948 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7949
7950 let error = repo
7951 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7952 .unwrap_err();
7953
7954 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7955 assert!(error.to_string().contains("target/user.yaml"), "{error}");
7956 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7957 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
7958 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
7959 }
7960
7961 #[test]
7962 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
7963 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
7964 let baseline = repo.worktree_baseline(&path).unwrap();
7965 std::fs::create_dir_all(path.join("target")).unwrap();
7966 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
7967
7968 assert!(repo
7969 .commit_pending_changes(&path, &baseline, "add file", "add file")
7970 .unwrap());
7971
7972 assert_eq!(
7973 "new file\n",
7974 test_git(&path, &["show", "HEAD:target/new.txt"])
7975 );
7976 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7977 }
7978
7979 #[test]
7980 fn deleting_existing_ignored_work_stops_a_managed_commit() {
7981 let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7982 std::fs::create_dir_all(path.join("generated")).unwrap();
7983 let ignored = path.join("generated/keep.txt");
7984 std::fs::write(&ignored, "user data\n").unwrap();
7985 let baseline = repo.worktree_baseline(&path).unwrap();
7986 let before = test_git(&path, &["rev-parse", "HEAD"]);
7987 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7988 std::fs::remove_file(&ignored).unwrap();
7989
7990 let error = repo
7991 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7992 .unwrap_err();
7993
7994 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7995 assert!(error.to_string().contains("existing untracked"), "{error}");
7996 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7997 assert_eq!(
7998 "tracked change\n",
7999 std::fs::read_to_string(path.join("README.md")).unwrap()
8000 );
8001 }
8002
8003 #[test]
8004 fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
8005 let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
8006 let baseline = repo.worktree_baseline(&path).unwrap();
8007 let before = test_git(&path, &["rev-parse", "HEAD"]);
8008 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8009 std::fs::create_dir_all(path.join("generated")).unwrap();
8010 let ignored = path.join("generated/recovery.txt");
8011 std::fs::write(&ignored, "keep me\n").unwrap();
8012
8013 let error = repo
8014 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8015 .unwrap_err();
8016
8017 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8018 assert!(error.to_string().contains("recovery.txt"), "{error}");
8019 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8020 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
8021 assert!(test_git(&path, &["status", "--porcelain"])
8022 .lines()
8023 .any(|line| line == "M README.md"));
8024 }
8025
8026 #[test]
8027 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
8028 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
8029 test_git(&path, &["config", "core.autocrlf", "true"]);
8030 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8031 assert_eq!(
8032 test_git(&path, &["hash-object", "README.md"]).trim(),
8033 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8034 );
8035
8036 assert!(repository_has_recoverable_work(&path, true).unwrap());
8037 assert_eq!(
8038 "seed\n",
8039 std::fs::read_to_string(path.join("README.md")).unwrap()
8040 );
8041 }
8042
8043 #[test]
8044 fn autocrlf_input_overrides_a_crlf_core_eol() {
8045 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8046 test_git(&path, &["config", "core.autocrlf", "input"]);
8047 test_git(&path, &["config", "core.eol", "crlf"]);
8048 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8049 assert_eq!(
8050 test_git(&path, &["hash-object", "README.md"]).trim(),
8051 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8052 );
8053
8054 assert!(repository_has_recoverable_work(&path, true).unwrap());
8055 assert_eq!(
8056 b"seed\r\n",
8057 std::fs::read(path.join("README.md")).unwrap().as_slice()
8058 );
8059 }
8060
8061 #[cfg(unix)]
8062 #[test]
8063 fn a_non_executable_permission_change_is_recoverable() {
8064 use std::os::unix::fs::PermissionsExt;
8065
8066 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8067 let readme = path.join("README.md");
8068 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8069 permissions.set_mode(0o600);
8070 std::fs::set_permissions(&readme, permissions).unwrap();
8071 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8072
8073 let error = repo
8074 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8075 .unwrap_err();
8076
8077 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8078 assert!(repository_has_recoverable_work(&path, true).unwrap());
8079 assert_eq!(
8080 0o600,
8081 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8082 );
8083 }
8084
8085 #[cfg(unix)]
8086 #[test]
8087 fn a_managed_commit_skips_signing_and_hooks() {
8088 use std::os::unix::fs::PermissionsExt;
8089
8090 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8091 let common = common_git_dir(&path).unwrap();
8092 let hook = common.join("hooks/pre-commit");
8093 let marker = fixture.root.join("hook-ran");
8094 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8095 std::fs::write(
8096 &hook,
8097 format!(
8098 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8099 sh_quote(marker.to_str().unwrap())
8100 ),
8101 )
8102 .unwrap();
8103 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8104 permissions.set_mode(0o755);
8105 std::fs::set_permissions(&hook, permissions).unwrap();
8106 test_git(&path, &["config", "commit.gpgsign", "true"]);
8107 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8108 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8109 test_git(&path, &["add", "managed.txt"]);
8110
8111 repo.commit_staged_changes(&path, "record managed change")
8112 .unwrap();
8113
8114 assert!(!marker.exists());
8115 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8116 }
8117
8118 #[test]
8119 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8120 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8121 std::fs::write(
8122 path.join(".gitattributes"),
8123 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8124 )
8125 .unwrap();
8126 test_git(&path, &["add", ".gitattributes"]);
8127 test_git(&path, &["commit", "-m", "select automatic text"]);
8128 test_git(&path, &["config", "core.autocrlf", "true"]);
8129
8130 assert!(repository_has_recoverable_work(&path, true).unwrap());
8131 }
8132
8133 #[test]
8134 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8135 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8136 std::fs::write(
8137 path.join(".gitattributes"),
8138 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8139 )
8140 .unwrap();
8141 test_git(&path, &["add", ".gitattributes"]);
8142 test_git(&path, &["commit", "-m", "select ident expansion"]);
8143 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8144
8145 assert!(repository_has_recoverable_work(&path, true).unwrap());
8146 }
8147
8148 #[test]
8154 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8155 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8156 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8157 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8158 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8159 std::fs::write(
8160 path.join("bindings/uniffi-bindgen/Cargo.lock"),
8161 "# generated\n",
8162 )
8163 .unwrap();
8164
8165 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8166 .expect("a path the project ignores is the call's own output");
8167 }
8168
8169 #[test]
8170 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8171 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8172 exclude_paths(&repo, &["target/"]);
8173 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8174 std::fs::create_dir_all(path.join("target/package")).unwrap();
8175 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8176
8177 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8178 .expect("an ignored attribute file governs only ignored paths");
8179 }
8180
8181 #[test]
8185 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
8186 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
8187 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8188 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
8189
8190 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8191 .expect("spar's own marker is not something a call did");
8192 }
8193
8194 #[test]
8195 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
8196 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
8197
8198 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
8199 .expect("a path a build removed is not an unreadable worktree");
8200
8201 assert_eq!(vanished_untracked_file(), gone);
8202 }
8203
8204 #[test]
8207 fn a_vanished_path_is_not_recorded_as_absent() {
8208 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
8209 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
8210 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8211 std::fs::remove_file(path.join("notes.txt")).unwrap();
8212
8213 let error = repo
8214 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8215 .unwrap_err();
8216
8217 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8218 }
8219
8220 fn exclude_paths(repo: &Repo, lines: &[&str]) {
8221 use std::io::Write;
8222 let path = repo.root().join(".git").join("info").join("exclude");
8223 let mut file = std::fs::OpenOptions::new()
8224 .create(true)
8225 .append(true)
8226 .open(&path)
8227 .unwrap();
8228 for line in lines {
8229 writeln!(file, "{line}").unwrap();
8230 }
8231 }
8232
8233 #[test]
8234 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
8235 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
8236 exclude_paths(&repo, &["dist/"]);
8237 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8238 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8239 let baseline = repo.worktree_baseline(&path).unwrap();
8240 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8241 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8242 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8243 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8244
8245 assert!(repo
8246 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8247 .unwrap());
8248 assert_eq!(
8249 "tracked change\n",
8250 std::fs::read_to_string(path.join("README.md")).unwrap()
8251 );
8252 }
8253
8254 #[test]
8255 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
8256 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
8257 exclude_paths(&repo, &["dist/"]);
8258 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8259 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8260 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
8261 let baseline = repo.worktree_baseline(&path).unwrap();
8262 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8263 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
8264 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8265 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8266
8267 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8268 repo.refuse_changed_existing_untracked(&path, &baseline)
8269 .unwrap();
8270 }
8271
8272 #[test]
8273 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
8274 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
8275 exclude_paths(&repo, &["dist/"]);
8276 std::fs::create_dir_all(path.join("dist")).unwrap();
8277 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8278 let baseline = repo.worktree_baseline(&path).unwrap();
8279 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8280 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8281
8282 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8283 repo.refuse_changed_existing_untracked(&path, &baseline)
8284 .unwrap();
8285 }
8286
8287 #[test]
8293 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
8294 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
8295 exclude_paths(&repo, &["manager/public/assets/"]);
8296 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
8297 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
8298 std::fs::write(&bundle, "first build\n").unwrap();
8299 let baseline = repo.worktree_baseline(&path).unwrap();
8300 std::fs::write(&bundle, "second build\n").unwrap();
8301 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
8302
8303 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
8304 assert!(repo.refuse_new_ignored_files(&path, &baseline).is_err());
8307 }
8308
8309 #[test]
8310 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
8311 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
8312 exclude_paths(&repo, &["dist/"]);
8313 let baseline = repo.worktree_baseline(&path).unwrap();
8314 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
8315
8316 let error = repo
8317 .refuse_read_only_leavings(&path, &baseline)
8318 .unwrap_err();
8319
8320 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8321 assert!(error.to_string().contains("scratch.md"), "{error}");
8322 }
8323
8324 #[test]
8325 fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
8326 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
8327 exclude_paths(&repo, &["dist/", ".env.local"]);
8328 std::fs::create_dir_all(path.join("dist")).unwrap();
8329 let baseline = repo.worktree_baseline(&path).unwrap();
8330 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
8331 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
8332
8333 let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
8334
8335 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8336 assert!(error.to_string().contains(".env.local"), "{error}");
8337 }
8338
8339 #[test]
8340 fn a_read_only_inspection_may_rebuild_generated_output() {
8341 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
8342 exclude_paths(&repo, &["dist/"]);
8343 std::fs::create_dir_all(path.join("dist")).unwrap();
8344 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8345 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8346 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8347 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8348
8349 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8350 .unwrap();
8351 }
8352
8353 #[test]
8354 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
8355 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
8356 exclude_paths(&repo, &["dist/", ".env.local"]);
8357 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8358 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8359 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
8360
8361 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8362 .unwrap();
8363 }
8364
8365 #[test]
8366 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
8367 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
8368 exclude_paths(&repo, &["dist/", ".env.local"]);
8369 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8370 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8371 std::fs::remove_file(path.join(".env.local")).unwrap();
8372
8373 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8374 .unwrap();
8375 }
8376
8377 #[test]
8378 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
8379 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
8380 exclude_paths(&repo, &["dist/"]);
8381 std::fs::write(path.join("notes.md"), "before\n").unwrap();
8382 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8383 std::fs::write(path.join("notes.md"), "after\n").unwrap();
8384
8385 let error = repo
8386 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8387 .unwrap_err();
8388
8389 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8390 assert!(error.to_string().contains("notes.md"), "{error}");
8391 }
8392
8393 #[test]
8394 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
8395 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
8396 exclude_paths(&repo, &["dist/", "local.env"]);
8397 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
8398 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8399 std::fs::write(
8402 repo.root().join(".git").join("info").join("exclude"),
8403 "dist/\n",
8404 )
8405 .unwrap();
8406
8407 let error = repo
8408 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8409 .unwrap_err();
8410
8411 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8412 assert!(error.to_string().contains("local.env"), "{error}");
8413 }
8414
8415 #[test]
8416 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
8417 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
8418 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8419 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8420
8421 let error = repo
8422 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8423 .unwrap_err();
8424
8425 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8426 assert!(error.to_string().contains("README.md"), "{error}");
8427 }
8428
8429 #[test]
8430 fn build_output_alone_does_not_keep_a_worktree() {
8431 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
8432 exclude_paths(&repo, &["target/", "dist/"]);
8433 std::fs::create_dir_all(path.join("target/debug")).unwrap();
8434 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8435 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
8436 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
8437
8438 assert!(!repository_has_recoverable_work(&path, true).unwrap());
8439 repo.release_review_worktree(933);
8440
8441 assert!(!path.exists());
8442 }
8443
8444 #[test]
8445 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
8446 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
8447 exclude_paths(&repo, &["target/", ".env.local"]);
8448 std::fs::create_dir_all(path.join("target/debug")).unwrap();
8449 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8450 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
8451
8452 assert!(repository_has_recoverable_work(&path, true).unwrap());
8453 repo.release_review_worktree(934);
8454
8455 assert_eq!(
8456 "TOKEN=keep me\n",
8457 std::fs::read_to_string(path.join(".env.local")).unwrap()
8458 );
8459 }
8460
8461 #[test]
8462 fn a_repository_nested_in_build_output_keeps_a_worktree() {
8463 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
8464 exclude_paths(&repo, &["node_modules/"]);
8465 let nested = path.join("node_modules/local-dep");
8466 std::fs::create_dir_all(&nested).unwrap();
8467 test_git(&nested, &["init"]);
8468 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
8469
8470 assert!(repository_has_recoverable_work(&path, true).unwrap());
8471 repo.release_review_worktree(935);
8472
8473 assert!(nested.join(".git").exists());
8474 }
8475
8476 #[test]
8477 fn an_ordinary_untracked_file_keeps_a_worktree() {
8478 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
8479 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
8480
8481 assert!(repository_has_recoverable_work(&path, true).unwrap());
8482 repo.release_review_worktree(936);
8483
8484 assert_eq!(
8485 "somebody's notes\n",
8486 std::fs::read_to_string(path.join("notes.md")).unwrap()
8487 );
8488 }
8489
8490 #[test]
8491 fn a_legacy_crlf_checkout_is_retained_conservatively() {
8492 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
8493 std::fs::write(
8494 path.join(".gitattributes"),
8495 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
8496 )
8497 .unwrap();
8498 test_git(&path, &["add", ".gitattributes"]);
8499 test_git(&path, &["commit", "-m", "select legacy line endings"]);
8500
8501 assert!(repository_has_recoverable_work(&path, true).unwrap());
8502 }
8503
8504 #[test]
8505 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
8506 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
8507 let nested = path.join("tracked");
8508 std::fs::create_dir_all(&nested).unwrap();
8509 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
8510 test_git(&path, &["add", "tracked/seed.txt"]);
8511 test_git(&path, &["commit", "-m", "add tracked directory"]);
8512 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8513 test_git(&nested, &["init"]);
8514
8515 let error = repo
8516 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8517 .unwrap_err();
8518
8519 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8520 assert!(error.to_string().contains("Git entry"), "{error}");
8521 assert!(nested.join(".git").exists());
8522 }
8523
8524 #[test]
8529 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
8530 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
8531 exclude_paths(&repo, &["target/"]);
8532 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8533 let repro = path.join("target/pr918-repro");
8534 std::fs::create_dir_all(&repro).unwrap();
8535 test_git(&repro, &["init"]);
8536 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
8537
8538 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8539 .unwrap();
8540
8541 assert!(repository_has_recoverable_work(&path, true).unwrap());
8542 assert!(repro.join(".git").exists());
8543 }
8544
8545 #[test]
8549 fn a_repository_inside_a_nested_checkout_is_left_alone() {
8550 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
8551 exclude_paths(&repo, &["target/"]);
8552 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8553 let repro = path.join("target/repro");
8554 let vendored = repro.join("vendor/dep");
8555 std::fs::create_dir_all(&vendored).unwrap();
8556 test_git(&repro, &["init"]);
8557 test_git(&vendored, &["init"]);
8558
8559 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8560 .unwrap();
8561
8562 assert!(vendored.join(".git").exists());
8563 }
8564
8565 #[test]
8566 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
8567 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
8568
8569 let state = ignored_untracked_state(repo.root()).unwrap();
8570
8571 let relative = path.strip_prefix(repo.root()).unwrap();
8572 assert!(
8573 state.files.contains_key(relative),
8574 "{:?}",
8575 state.files.keys().collect::<Vec<_>>()
8576 );
8577 assert!(state.is_ignored(relative));
8578 }
8579
8580 #[test]
8581 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
8582 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
8583 let baseline = repo.worktree_baseline(repo.root()).unwrap();
8584 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
8585 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
8586
8587 repo.refuse_new_ignored_files(repo.root(), &baseline)
8588 .unwrap();
8589 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
8590 .unwrap();
8591 }
8592
8593 #[test]
8594 fn deleting_a_resident_worktree_during_a_call_is_refused() {
8595 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
8596 let baseline = repo.worktree_baseline(repo.root()).unwrap();
8597 std::fs::remove_dir_all(&path).unwrap();
8598
8599 let error = repo
8600 .refuse_new_ignored_files(repo.root(), &baseline)
8601 .unwrap_err();
8602
8603 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8604 assert!(error.to_string().contains("review-932"), "{error}");
8605 }
8606
8607 #[test]
8608 fn a_nested_repository_record_is_read_as_a_plain_path() {
8609 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
8610 assert_eq!(Path::new("vendor/checkout"), path);
8611 assert!(nested);
8612
8613 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
8614 assert_eq!(Path::new("vendor/notes.txt"), path);
8615 assert!(!nested);
8616
8617 assert!(untracked_record(b"/", "untracked").is_err());
8618 }
8619
8620 #[cfg(unix)]
8621 #[test]
8622 fn a_non_utf8_git_path_is_preserved_without_loss() {
8623 use std::os::unix::ffi::OsStrExt;
8624
8625 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
8626
8627 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
8628 }
8629
8630 #[test]
8631 fn guarded_merge_pins_the_reviewed_head() {
8632 let args = merge_pr_args("36", Some("abc123"), true);
8633 assert_eq!(
8634 vec![
8635 "pr",
8636 "merge",
8637 "36",
8638 "--squash",
8639 "--delete-branch",
8640 "--match-head-commit",
8641 "abc123"
8642 ],
8643 args
8644 );
8645 }
8646
8647 #[test]
8648 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
8649 let pr = PrRef {
8650 number: 7,
8651 url: "https://example.test/pull/7".into(),
8652 title: "part one".into(),
8653 };
8654 let result = reconcile_pr_creation(
8655 "split-34-1",
8656 Err(crate::error::SparError::new("connection lost")),
8657 Ok(Some(pr)),
8658 )
8659 .unwrap();
8660 assert_eq!(7, result.number);
8661 }
8662
8663 #[test]
8664 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
8665 let error = reconcile_pr_creation(
8666 "split-34-1",
8667 Err(crate::error::SparError::new("permission denied")),
8668 Ok(None),
8669 )
8670 .unwrap_err();
8671 assert!(error.to_string().contains("permission denied"), "{error}");
8672 }
8673
8674 #[test]
8675 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
8676 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
8677 assert!(pr_for_base(text, "split-34-2", "split-34-1")
8678 .unwrap()
8679 .is_none());
8680 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
8681 assert_eq!(7, found.number);
8682 }
8683
8684 #[test]
8685 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
8686 let result = reconcile_comment_post(
8687 34,
8688 "the summary",
8689 crate::error::SparError::new("connection lost"),
8690 Ok(vec![serde_json::json!({"body": "the summary"})]),
8691 );
8692 assert!(result.is_ok(), "{result:?}");
8693 }
8694
8695 #[test]
8696 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
8697 let error = reconcile_comment_post(
8698 34,
8699 "the summary",
8700 crate::error::SparError::new("connection lost"),
8701 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
8702 )
8703 .unwrap_err();
8704 assert_eq!("connection lost", error.to_string());
8705 }
8706
8707 #[test]
8708 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
8709 let error = reconcile_comment_post(
8710 34,
8711 "the summary",
8712 crate::error::SparError::new("connection lost"),
8713 Err(crate::error::SparError::new("comments unavailable")),
8714 )
8715 .unwrap_err();
8716 assert!(
8717 error.to_string().contains("could not be verified"),
8718 "{error}"
8719 );
8720 assert!(
8721 error.to_string().contains("comments unavailable"),
8722 "{error}"
8723 );
8724 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8725 assert!(!error.worth_retrying());
8726 }
8727
8728 #[test]
8729 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
8730 let result = reconcile_issue_edit(
8731 34,
8732 "wanted body",
8733 crate::error::SparError::new("connection lost"),
8734 Ok("wanted body".to_string()),
8735 );
8736 assert!(result.is_ok(), "{result:?}");
8737 }
8738
8739 #[test]
8740 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
8741 let error = reconcile_issue_edit(
8742 34,
8743 "wanted body",
8744 crate::error::SparError::new("connection lost"),
8745 Err(crate::error::SparError::new("issue unavailable")),
8746 )
8747 .unwrap_err();
8748 assert!(
8749 error.to_string().contains("could not be verified"),
8750 "{error}"
8751 );
8752 assert!(error.to_string().contains("issue unavailable"), "{error}");
8753 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8754 assert!(!error.worth_retrying());
8755 }
8756
8757 #[test]
8758 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
8759 let found = ExistingIssue {
8760 number: 101,
8761 url: "https://example.test/issues/101".into(),
8762 title: "child".into(),
8763 body: "body".into(),
8764 open: true,
8765 };
8766 let url = reconcile_issue_creation(
8767 "child",
8768 Err(crate::error::SparError::new("connection lost")),
8769 Ok(Some(found)),
8770 )
8771 .unwrap();
8772 assert_eq!("https://example.test/issues/101", url);
8773 }
8774
8775 #[test]
8776 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8777 let error = reconcile_issue_creation(
8778 "child",
8779 Err(crate::error::SparError::new("permission denied")),
8780 Ok(None),
8781 )
8782 .unwrap_err();
8783 assert!(error.to_string().contains("permission denied"), "{error}");
8784 }
8785
8786 #[test]
8787 fn an_unverifiable_issue_creation_is_marked_uncertain() {
8788 let error = reconcile_issue_creation(
8789 "child",
8790 Err(crate::error::SparError::new("connection lost")),
8791 Err(crate::error::SparError::new("issues unavailable")),
8792 )
8793 .unwrap_err();
8794 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8795 assert!(!error.worth_retrying());
8796 }
8797
8798 #[test]
8799 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8800 let result = reconcile_failed_split_push(
8801 "split-34-1",
8802 crate::error::SparError::new("connection lost"),
8803 Ok("abc123\n".into()),
8804 Ok("abc123\trefs/heads/split-34-1\n".into()),
8805 );
8806 assert!(result.is_ok(), "{result:?}");
8807 }
8808
8809 #[test]
8810 fn a_split_push_collision_is_definite_and_never_overwrites() {
8811 let error = reconcile_failed_split_push(
8812 "split-34-1",
8813 crate::error::SparError::new("lease rejected"),
8814 Ok("abc123\n".into()),
8815 Ok("def456\trefs/heads/split-34-1\n".into()),
8816 )
8817 .unwrap_err();
8818 assert!(!error.retain_worktree());
8819 assert!(
8820 error.to_string().contains("Nothing was overwritten"),
8821 "{error}"
8822 );
8823 }
8824
8825 #[test]
8826 fn an_unreadable_split_push_result_keeps_the_worktree() {
8827 let error = reconcile_failed_split_push(
8828 "split-34-1",
8829 crate::error::SparError::new("connection lost"),
8830 Ok("abc123\n".into()),
8831 Err(crate::error::SparError::new("origin unavailable")),
8832 )
8833 .unwrap_err();
8834 assert!(error.retain_worktree());
8835 assert!(error.to_string().contains("could not confirm"), "{error}");
8836 }
8837
8838 #[test]
8842 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8843 let repo = repo_for_titles();
8844 for raw in [
8845 "Retry loop spins \u{2014} Retry-After parses to zero",
8846 "plain title",
8847 " spread over\nlines ",
8848 "\u{1F916} Generated with something",
8849 &format!("a \u{2014} {}", "very long title ".repeat(20)),
8850 &"x".repeat(300),
8851 &format!("{} \u{2014} end", "y".repeat(88)),
8852 &{
8857 let tail = "a\u{2014}b c\u{2014}d";
8858 let pad = Style::default().max_title_chars - tail.chars().count();
8859 format!("{}{tail}", "w".repeat(pad))
8860 },
8861 ] {
8862 let once = repo.clean_title(raw).unwrap();
8863 let twice = repo.clean_title(&once).unwrap();
8864 assert_eq!(once, twice, "not idempotent for {raw:?}");
8865 assert!(
8866 once.chars().count() <= repo.style.max_title_chars,
8867 "over budget: {once:?}"
8868 );
8869 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8870 }
8871 }
8872
8873 #[test]
8874 fn a_title_with_an_em_dash_survives_as_readable_text() {
8875 let repo = repo_for_titles();
8876 assert_eq!(
8877 "Retry loop spins, Retry-After parses to zero",
8878 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8879 .unwrap()
8880 );
8881 }
8882
8883 #[test]
8884 fn sh_quote_survives_a_quote() {
8885 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
8886 }
8887
8888 #[test]
8889 fn sh_quote_wraps_a_space() {
8890 assert_eq!(
8891 "'/Applications/My App/spar'",
8892 sh_quote("/Applications/My App/spar")
8893 );
8894 }
8895
8896 #[test]
8897 fn finished_states_are_recognised_case_insensitively() {
8898 assert!(is_finished("MERGED"));
8899 assert!(is_finished("closed"));
8900 assert!(!is_finished("OPEN"));
8901 assert!(!is_finished(""));
8902 }
8903
8904 fn state() -> PersistedState {
8905 PersistedState {
8906 version: 1,
8907 checkpoint: 0,
8908 round: 4,
8909 next_actor: "codex".into(),
8910 status: Status::Pending,
8911 pr_head: "abc123".into(),
8912 ledger: Ledger::new(),
8913 filed: vec![],
8914 open_findings: vec![Finding {
8915 severity: Severity::Blocking,
8916 title: "Unchecked error".into(),
8917 detail: "the failure is discarded".into(),
8918 file: "src/a.rs:12".into(),
8919 ..Finding::default()
8920 }],
8921 disputes: vec![Dispute {
8922 title: "Retry limit".into(),
8923 file: "src/net.rs".into(),
8924 reasoning: "the caller already bounds it".into(),
8925 }],
8926 noted: vec![Finding {
8927 severity: Severity::NonBlocking,
8928 title: "Timeout is fixed".into(),
8929 file: "src/config.rs".into(),
8930 ..Finding::default()
8931 }],
8932 }
8933 }
8934
8935 #[test]
8936 fn a_state_comment_round_trips() {
8937 let body = format!(
8938 "{STATE_MARKER}\n{}\n-->",
8939 serde_json::to_string(&state()).unwrap()
8940 );
8941 let back = parse_state_comment(&body).unwrap();
8942 assert_eq!(4, back.round);
8943 assert_eq!("codex", back.next_actor);
8944 assert_eq!("abc123", back.pr_head);
8945 assert_eq!("Unchecked error", back.open_findings[0].title);
8946 assert_eq!("src/net.rs", back.disputes[0].file);
8947 assert_eq!("Timeout is fixed", back.noted[0].title);
8948 }
8949
8950 #[test]
8951 fn old_state_without_new_lists_still_parses() {
8952 let body = format!(
8953 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
8954 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8955 );
8956 let back = parse_state_comment(&body).expect("old state");
8957 assert!(back.open_findings.is_empty());
8958 assert!(back.disputes.is_empty());
8959 assert!(back.noted.is_empty());
8960 assert!(back.pr_head.is_empty());
8961 assert_eq!(0, back.checkpoint);
8962 }
8963
8964 #[test]
8965 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
8966 let mut local = state();
8967 local.pr_head = "old".into();
8968 local.round = 9;
8969 let mut remote = state();
8970 remote.pr_head = "current".into();
8971 remote.round = 4;
8972
8973 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8974 assert_eq!("current", chosen.pr_head);
8975 assert_eq!(4, chosen.round);
8976 }
8977
8978 #[test]
8979 fn checkpoint_order_breaks_same_round_ties() {
8980 let mut local = state();
8981 local.pr_head = "current".into();
8982 local.round = 4;
8983 local.checkpoint = 8;
8984 let mut remote = local.clone();
8985 remote.checkpoint = 7;
8986 remote.open_findings.clear();
8987
8988 let chosen = choose_state_for_head(vec![local], "current").unwrap();
8989 assert_eq!(8, chosen.checkpoint);
8990
8991 let mut local = state();
8992 local.pr_head = "current".into();
8993 local.round = 4;
8994 local.checkpoint = 8;
8995 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
8996 assert_eq!(8, chosen.checkpoint);
8997 }
8998
8999 #[test]
9000 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
9001 let mut local = state();
9002 local.pr_head = "current".into();
9003 local.round = 4;
9004 local.open_findings.push(Finding {
9005 title: "local checkpoint".into(),
9006 ..Finding::default()
9007 });
9008 let mut remote = state();
9009 remote.pr_head = "current".into();
9010 remote.round = 4;
9011
9012 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9013 assert_eq!(
9014 "local checkpoint",
9015 chosen.open_findings.last().unwrap().title
9016 );
9017 }
9018
9019 #[test]
9021 fn the_state_block_is_an_html_comment() {
9022 let body = format!(
9023 "{STATE_MARKER}\n{}\n-->",
9024 serde_json::to_string(&state()).unwrap()
9025 );
9026 assert!(body.starts_with("<!--"));
9027 assert!(body.trim_end().ends_with("-->"));
9028 assert!(!body[..body.find('{').unwrap()].contains("-->"));
9029 }
9030
9031 #[test]
9032 fn an_unrelated_json_block_is_not_state() {
9033 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
9034 }
9035
9036 #[test]
9043 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
9044 let block = format!(
9045 "{STATE_MARKER}\n{}\n-->",
9046 serde_json::to_string(&state()).unwrap()
9047 );
9048 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
9049 assert!(
9050 is_state_comment(&block, "Spar-Bot", "spar-bot"),
9051 "a login differs only in case"
9052 );
9053
9054 assert!(
9055 !is_state_comment(
9056 "why is there a `<!-- spar:state` block on this PR?",
9057 "maintainer",
9058 "spar-bot"
9059 ),
9060 "a question about the block was treated as the block"
9061 );
9062 assert!(
9063 !is_state_comment(&block, "maintainer", "spar-bot"),
9064 "somebody else's comment is not spar's to rewrite"
9065 );
9066 assert!(
9067 !is_state_comment(
9068 &format!("here is what spar writes:\n\n{block}"),
9069 "spar-bot",
9070 "spar-bot"
9071 ),
9072 "the marker has to open the body, not appear in it"
9073 );
9074 assert!(
9075 !is_state_comment(
9076 &format!("{STATE_MARKER}\n{{not json\n-->"),
9077 "spar-bot",
9078 "spar-bot"
9079 ),
9080 "a block that does not parse is not state"
9081 );
9082 assert!(
9083 !is_state_comment(&block, "spar-bot", ""),
9084 "an unknown viewer proves nothing about who wrote it"
9085 );
9086 }
9087
9088 #[test]
9089 fn a_malformed_state_comment_is_none_not_a_panic() {
9090 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
9091 }
9092
9093 #[test]
9094 fn atomic_write_leaves_no_temp_file() {
9095 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
9096 let _ = std::fs::remove_dir_all(&dir);
9097 let path = dir.join("state").join("pr-7.json");
9098 write_json_atomic(&path, &state()).unwrap();
9099 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
9100 .unwrap()
9101 .flatten()
9102 .filter_map(|e| e.file_name().to_str().map(str::to_string))
9103 .collect();
9104 assert_eq!(vec!["pr-7.json".to_string()], files);
9105 let _ = std::fs::remove_dir_all(&dir);
9106 }
9107
9108 #[test]
9109 fn atomic_write_overwrites_rather_than_accumulating() {
9110 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
9111 let _ = std::fs::remove_dir_all(&dir);
9112 let path = dir.join("pr-7.json");
9113 for round in 1..4 {
9114 let mut s = state();
9115 s.round = round;
9116 write_json_atomic(&path, &s).unwrap();
9117 }
9118 let back: PersistedState =
9119 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9120 assert_eq!(3, back.round);
9121 let _ = std::fs::remove_dir_all(&dir);
9122 }
9123
9124 #[test]
9125 fn style_from_env_defaults_to_enforcing() {
9126 std::env::remove_var("SPAR_BAN_EM_DASH");
9127 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
9128 let style = style_from_env();
9129 assert!(style.ban_em_dash && style.ban_ai_attribution);
9130 assert!(
9131 !style.terse,
9132 "the commit filter must not truncate a commit message"
9133 );
9134 }
9135}
9136
9137#[cfg(test)]
9138mod comment_page_tests {
9139 use super::*;
9140
9141 #[test]
9142 fn a_single_merged_array_is_read() {
9143 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
9144 assert_eq!(2, pages.len());
9145 assert_eq!(Some(2), pages[1]["id"].as_i64());
9146 }
9147
9148 #[test]
9149 fn concatenated_pages_from_an_older_gh_are_read_too() {
9150 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
9151 assert_eq!(2, pages.len());
9152 }
9153
9154 #[test]
9158 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
9159 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
9160 let pages = parse_comment_pages(text);
9161 assert_eq!(2, pages.len(), "{pages:?}");
9162 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
9163 }
9164
9165 #[test]
9166 fn empty_output_is_no_comments_not_a_panic() {
9167 assert!(parse_comment_pages("").is_empty());
9168 assert!(parse_comment_pages(" ").is_empty());
9169 assert!(parse_comment_pages("[]").is_empty());
9170 }
9171
9172 #[test]
9173 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
9174 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
9175 }
9176
9177 #[test]
9178 fn a_write_postcheck_rejects_truncated_comment_pages() {
9179 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
9180 assert!(
9181 error.to_string().contains("unexpected comment pages"),
9182 "{error}"
9183 );
9184 }
9185
9186 #[test]
9187 fn a_write_postcheck_rejects_empty_or_non_array_output() {
9188 assert!(try_parse_comment_pages("").is_err());
9189 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
9190 assert!(try_parse_comment_pages("[]").is_ok());
9191 }
9192
9193 #[test]
9194 fn state_is_found_in_the_last_matching_comment() {
9195 let payload = |round: u32| {
9196 format!(
9197 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9198 )
9199 };
9200 let text = serde_json::to_string(&serde_json::json!([
9201 {"id": 1, "body": payload(1)},
9202 {"id": 2, "body": "looks good to me"},
9203 {"id": 3, "body": payload(5)},
9204 ]))
9205 .unwrap();
9206 let pages = parse_comment_pages(&text);
9207 let last = pages
9208 .iter()
9209 .rev()
9210 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
9211 .unwrap();
9212 assert_eq!(5, last.round);
9213 }
9214}
9215
9216#[cfg(test)]
9217mod linked_pr_tests {
9218 use super::*;
9219
9220 const REAL_PAYLOAD: &str = r#"[
9225 {"number":14252,"title":"fix: reject leading-dash branch names",
9226 "url":"https://github.com/cli/cli/pull/14252",
9227 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
9228 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9229 "url":"https://github.com/cli/cli/issues/14238"}]},
9230 {"number":14217,"title":"another change",
9231 "url":"https://github.com/cli/cli/pull/14217",
9232 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
9233 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9234 "url":"https://github.com/cli/cli/issues/9761"}]},
9235 {"number":14200,"title":"unlinked work",
9236 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
9237 ]"#;
9238
9239 #[test]
9240 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
9241 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
9242 assert_eq!(14252, pr.number);
9243 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
9244 }
9245
9246 #[test]
9247 fn the_right_pr_is_picked_out_of_several() {
9248 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
9249 }
9250
9251 #[test]
9252 fn an_issue_nobody_is_working_on_finds_nothing() {
9253 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
9254 }
9255
9256 #[test]
9257 fn an_unlinked_pr_is_never_matched() {
9258 for issue in [14200, 0, 1] {
9260 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
9261 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
9262 }
9263 }
9264 }
9265
9266 #[test]
9267 fn empty_or_broken_output_is_none_rather_than_a_panic() {
9268 assert!(find_linked_pr("", 1).is_none());
9269 assert!(find_linked_pr("[]", 1).is_none());
9270 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
9271 assert!(find_linked_pr("[{\"number\":", 1).is_none());
9272 }
9273
9274 #[test]
9281 fn an_unreadable_list_is_not_an_empty_list() {
9282 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
9283 assert!(linked_pr_rows("[{\"number\":").is_none());
9284 assert!(linked_pr_rows("").is_none());
9285
9286 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
9287 assert!(empty.is_empty());
9288 assert!(pick_linked_pr(&empty, 14238).is_none());
9289
9290 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
9291 assert_eq!(3, listed.len());
9292 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
9293 }
9294
9295 #[test]
9297 fn pr_view_reads_the_cross_repository_flag() {
9298 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
9299 "baseRefName":"main","state":"OPEN",
9300 "closingIssuesReferences":[],"isCrossRepository":true}"#;
9301 let pr: PrView = serde_json::from_str(json).unwrap();
9302 assert!(pr.is_cross_repository);
9303 assert!(pr.is_open());
9304
9305 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
9306 assert!(
9307 !serde_json::from_str::<PrView>(&same_repo)
9308 .unwrap()
9309 .is_cross_repository
9310 );
9311 }
9312}
9313
9314#[cfg(test)]
9315mod min_number_tests {
9316 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
9322 let mut numbers: Vec<i64> = open.to_vec();
9323 numbers.sort_unstable();
9324 if min_number > 0 {
9325 numbers.retain(|n| *n >= min_number);
9326 }
9327 numbers.truncate(limit);
9328 numbers
9329 }
9330
9331 #[test]
9332 fn the_floor_is_applied_before_the_cap_not_after() {
9333 let open = [12, 13, 14, 480, 481, 482];
9334 assert_eq!(vec![480, 481], pick(&open, 2, 480));
9335 assert!(!pick(&open, 2, 480).is_empty());
9338 }
9339
9340 #[test]
9341 fn no_floor_keeps_the_old_behaviour() {
9342 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
9343 }
9344
9345 #[test]
9346 fn the_floor_is_inclusive() {
9347 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
9348 }
9349
9350 #[test]
9351 fn a_floor_above_everything_open_yields_nothing() {
9352 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
9353 }
9354}