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 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
4152 let listed = self
4153 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
4154 .ok()?;
4155 let wanted = format!("branch refs/heads/{branch}");
4156 let mut current: Option<PathBuf> = None;
4157 for line in listed.lines() {
4158 if let Some(path) = line.strip_prefix("worktree ") {
4159 current = Some(PathBuf::from(path));
4160 } else if line == wanted {
4161 return current;
4162 }
4163 }
4164 None
4165 }
4166
4167 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
4183 let branch = self.pr_view(number).ok()?.head_ref_name;
4184 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
4185 return None;
4186 }
4187 let mut released = None;
4188 if let Some(path) = self.worktree_holding(&branch) {
4189 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
4190 return None;
4191 }
4192 if !matches!(
4193 self.has_recoverable_work(&path, Disposable::BuildOutput),
4194 Ok(false)
4195 ) {
4196 return None;
4197 }
4198 match self.remove_worktree_at(&path) {
4199 Ok(true) => released = Some(path),
4200 _ => return None,
4201 }
4202 }
4203 match self.delete_branch_if_safe(&branch) {
4204 Ok(true) => {
4205 self.forget_branch(&branch);
4206 Some(match released {
4207 Some(path) => format!(
4208 " The worktree at {} was released and branch {branch} deleted.",
4209 path.display()
4210 ),
4211 None => format!(" Branch {branch} was deleted."),
4212 })
4213 }
4214 _ => released.map(|path| {
4215 format!(
4216 " The worktree at {} was released, but branch {branch} was kept.",
4217 path.display()
4218 )
4219 }),
4220 }
4221 }
4222
4223 pub fn merge_pr(&self, number: i64) -> Result<()> {
4227 let n = number.to_string();
4228 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4229 Ok(_) => Ok(()),
4230 Err(e) => {
4231 if self.pr_state(number) == "MERGED" {
4232 let finished = self
4233 .finish_merged_branch_cleanup(number)
4234 .unwrap_or_default();
4235 logdim!(
4236 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4237 e.last_line()
4238 );
4239 Ok(())
4240 } else {
4241 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4242 }
4243 }
4244 };
4245 self.record_write(merged)
4246 }
4247
4248 pub fn merge_pr_at_head(
4250 &self,
4251 number: i64,
4252 expected_head: &str,
4253 delete_branch: bool,
4254 ) -> Result<()> {
4255 let n = number.to_string();
4256 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4257 Ok(_) => Ok(()),
4258 Err(e) => {
4259 if self.pr_state(number) == "MERGED" {
4260 let finished = self
4261 .finish_merged_branch_cleanup(number)
4262 .unwrap_or_default();
4263 logdim!(
4264 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4265 e.last_line()
4266 );
4267 Ok(())
4268 } else {
4269 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4270 }
4271 }
4272 };
4273 self.record_write(merged)
4274 }
4275
4276 pub fn followups_path(&self) -> PathBuf {
4281 self.root.join(STATE_DIR).join("followups.md")
4282 }
4283
4284 pub fn brainstorms_dir(&self) -> PathBuf {
4289 self.root.join(STATE_DIR).join("brainstorms")
4290 }
4291
4292 pub fn worked_followups_path(&self) -> PathBuf {
4300 self.root.join(STATE_DIR).join("followups.done.md")
4301 }
4302
4303 pub fn spend_path(&self) -> PathBuf {
4306 self.root.join(STATE_DIR).join("state").join("spend.json")
4307 }
4308
4309 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4315 if calls.is_empty() {
4316 return Ok(());
4317 }
4318 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4319 .ok()
4320 .and_then(|text| serde_json::from_str(&text).ok())
4321 .unwrap_or_default();
4322 all.extend(calls.iter().cloned());
4323 if let Some(parent) = self.spend_path().parent() {
4324 let _ = std::fs::create_dir_all(parent);
4325 }
4326 write_json_atomic(&self.spend_path(), &all)
4327 }
4328
4329 pub fn implementors_path(&self) -> PathBuf {
4335 self.root
4336 .join(STATE_DIR)
4337 .join("state")
4338 .join("implementors.json")
4339 }
4340
4341 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4342 std::fs::read_to_string(self.implementors_path())
4343 .ok()
4344 .and_then(|text| serde_json::from_str(&text).ok())
4345 .unwrap_or_default()
4346 }
4347
4348 pub fn remember_implementor(&self, issue: i64, agent: &str) {
4351 let mut known = self.read_implementors();
4352 known.insert(issue, agent.to_string());
4353 if let Some(parent) = self.implementors_path().parent() {
4354 let _ = std::fs::create_dir_all(parent);
4355 }
4356 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4357 logdim!("could not record who implemented #{issue}: {e}");
4358 }
4359 }
4360
4361 pub fn contested_path(&self) -> PathBuf {
4366 self.root
4367 .join(STATE_DIR)
4368 .join("state")
4369 .join("contested.json")
4370 }
4371
4372 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4374 std::fs::read_to_string(self.contested_path())
4375 .ok()
4376 .and_then(|text| serde_json::from_str(&text).ok())
4377 .unwrap_or_default()
4378 }
4379
4380 pub fn remember_contested(&self, items: &[ContestedItem]) {
4385 if items.is_empty() {
4386 return;
4387 }
4388 let mut known = self.read_contested();
4389 for item in items {
4390 known.insert(
4391 item.issue,
4392 Contested {
4393 title: item.title.clone(),
4394 positions: item.positions.clone(),
4395 reasons: item.reasons.clone(),
4396 },
4397 );
4398 }
4399 if let Some(parent) = self.contested_path().parent() {
4400 let _ = std::fs::create_dir_all(parent);
4401 }
4402 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4403 logdim!("could not record the contested issues: {e}");
4404 }
4405 }
4406
4407 pub fn forget_contested(&self, numbers: &[i64]) {
4409 let mut known = self.read_contested();
4410 let before = known.len();
4411 known.retain(|number, _| !numbers.contains(number));
4412 if known.len() == before {
4413 return;
4414 }
4415 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4416 logdim!("could not update the contested issues: {e}");
4417 }
4418 }
4419
4420 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4421 self.root
4422 .join(STATE_DIR)
4423 .join("state")
4424 .join(format!("checkin-{number}.json"))
4425 }
4426
4427 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4442 let path = self.followups_path();
4443 let heading = format!("## {}", title.trim());
4444 for seen in [&path, &self.worked_followups_path()] {
4445 if let Ok(existing) = std::fs::read_to_string(seen) {
4446 if has_heading(&existing, title) {
4447 logdim!("follow-up already noted: {title}");
4448 return Followup::Covered(format!("note: {}", title.trim()));
4449 }
4450 }
4451 }
4452 if let Some(parent) = path.parent() {
4453 let _ = std::fs::create_dir_all(parent);
4454 }
4455 use std::io::Write;
4456 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4463 match std::fs::OpenOptions::new()
4464 .create(true)
4465 .append(true)
4466 .open(&path)
4467 {
4468 Ok(mut file) => match file.write_all(entry.as_bytes()) {
4469 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4470 Err(e) => {
4471 logdim!("could not write {}: {e}", path.display());
4472 Followup::Failed
4473 }
4474 },
4475 Err(e) => {
4476 logdim!("could not write {}: {e}", path.display());
4477 Followup::Failed
4478 }
4479 }
4480 }
4481
4482 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4487 let path = self.worked_followups_path();
4488 if let Some(parent) = path.parent() {
4489 let _ = std::fs::create_dir_all(parent);
4490 }
4491 use std::io::Write;
4492 let entry = format!(
4493 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4494 title.trim(),
4495 body.trim()
4496 );
4497 if let Ok(mut file) = std::fs::OpenOptions::new()
4498 .create(true)
4499 .append(true)
4500 .open(&path)
4501 {
4502 let _ = file.write_all(entry.as_bytes());
4503 }
4504 }
4505
4506 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4515 self.root
4516 .join(STATE_DIR)
4517 .join("reviews")
4518 .join(format!("pr-{number}.md"))
4519 }
4520
4521 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4527 let path = self.pending_comment_path(number);
4528 if let Some(parent) = path.parent() {
4529 std::fs::create_dir_all(parent)
4530 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4531 }
4532 std::fs::write(&path, text)
4533 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4534 Ok(path)
4535 }
4536
4537 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4538 std::fs::read_to_string(self.pending_comment_path(number)).ok()
4539 }
4540
4541 pub fn state_path(&self, number: i64) -> PathBuf {
4542 self.root
4543 .join(STATE_DIR)
4544 .join("state")
4545 .join(format!("pr-{number}.json"))
4546 }
4547
4548 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4549 let path = self.state_path(number);
4550 let text = std::fs::read_to_string(&path).ok()?;
4551 match serde_json::from_str(&text) {
4552 Ok(state) => Some(state),
4553 Err(_) => {
4554 logdim!("could not read {}, starting fresh", path.display());
4555 None
4556 }
4557 }
4558 }
4559
4560 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4561 if let Some(local) = self.read_local_state(pr.number) {
4562 return Some(local);
4563 }
4564 if self.state_store.writes_pr() {
4565 return self.read_pr_state(pr.number);
4566 }
4567 None
4568 }
4569
4570 pub(crate) fn read_state_for_head(
4571 &self,
4572 pr: &PrView,
4573 actual_head: &str,
4574 ) -> Option<PersistedState> {
4575 let local = self
4576 .state_store
4577 .writes_local()
4578 .then(|| self.read_local_state(pr.number))
4579 .flatten();
4580 let remote = self
4581 .state_store
4582 .writes_pr()
4583 .then(|| self.read_pr_state(pr.number))
4584 .flatten();
4585 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4586 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4587 self.remember_checkpoint(pr.number, checkpoint);
4588 }
4589 choose_state_for_head(candidates, actual_head)
4590 }
4591
4592 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4593 self.try_read_pr_state(number).ok().flatten()
4594 }
4595
4596 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4597 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4598 if let Some(state) = parse_state_comment(&body) {
4599 return Ok(Some(state));
4600 }
4601 }
4602 Ok(None)
4603 }
4604
4605 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4606 let remote_state = if self.state_store.writes_pr() {
4607 self.try_read_pr_state(number)
4608 } else {
4609 Ok(None)
4610 };
4611 self.write_state_after_remote_read(number, state, remote_state)
4612 }
4613
4614 fn write_state_after_remote_read(
4615 &self,
4616 number: i64,
4617 state: &PersistedState,
4618 remote_state: Result<Option<PersistedState>>,
4619 ) -> Result<()> {
4620 let remote_checkpoint = if self.state_store.writes_pr() {
4621 self.record_failed_write(remote_state)?
4622 .map(|saved| saved.checkpoint)
4623 .unwrap_or_default()
4624 } else {
4625 0
4626 };
4627 let local_checkpoint = self
4628 .state_store
4629 .writes_local()
4630 .then(|| self.read_local_state(number))
4631 .flatten()
4632 .map(|saved| saved.checkpoint)
4633 .unwrap_or_default();
4634 let mut stamped = state.clone();
4635 stamped.checkpoint = state
4636 .checkpoint
4637 .max(local_checkpoint)
4638 .max(remote_checkpoint)
4639 .max(self.remembered_checkpoint(number))
4640 .saturating_add(1);
4641 self.remember_checkpoint(number, stamped.checkpoint);
4642 if self.state_store.writes_local() {
4643 write_json_atomic(&self.state_path(number), &stamped)?;
4644 }
4645 if self.state_store.writes_pr() {
4646 self.write_pr_state(number, &stamped)?;
4647 }
4648 Ok(())
4649 }
4650
4651 fn remembered_checkpoint(&self, number: i64) -> u64 {
4652 self.checkpoints
4653 .lock()
4654 .unwrap_or_else(std::sync::PoisonError::into_inner)
4655 .get(&number)
4656 .copied()
4657 .unwrap_or_default()
4658 }
4659
4660 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4661 let mut checkpoints = self
4662 .checkpoints
4663 .lock()
4664 .unwrap_or_else(std::sync::PoisonError::into_inner);
4665 let saved = checkpoints.entry(number).or_default();
4666 *saved = (*saved).max(checkpoint);
4667 }
4668
4669 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4670 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4674 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4675 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4676 if let Some(id) = comment_id {
4677 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4678 let field = format!("body={body}");
4679 let written = self
4680 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4681 .map(|_| ());
4682 return self.record_write(written);
4683 }
4684 let written = self
4685 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4686 .map(|_| ());
4687 self.record_write(written)
4688 }
4689
4690 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4698 self.try_issue_comments(number).unwrap_or_default()
4699 }
4700
4701 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4702 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4703 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4704 }
4705
4706 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4713 let viewer = self.viewer_login()?.to_string();
4714 let mut mine = Vec::new();
4715 let mut theirs = Vec::new();
4716 for comment in self.try_issue_comments(number)? {
4717 let Some(body) = comment.get("body").and_then(Value::as_str) else {
4718 continue;
4719 };
4720 if !body.contains("spar:state") {
4721 continue;
4722 }
4723 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4724 continue;
4725 };
4726 let author = comment
4727 .get("user")
4728 .and_then(|u| u.get("login"))
4729 .and_then(Value::as_str)
4730 .unwrap_or_default();
4731 if is_state_comment(body, author, &viewer) {
4732 mine.push((id, body.to_string()));
4733 } else {
4734 theirs.push(format!("@{author}"));
4735 }
4736 }
4737 if !theirs.is_empty() {
4738 logdim!(
4739 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4740 own state block is patched or deleted.",
4741 theirs.len(),
4742 theirs.join(", ")
4743 );
4744 }
4745 Ok(mine)
4746 }
4747
4748 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4749 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4750 }
4751
4752 pub fn clear_state(&self, number: i64) {
4754 let path = self.state_path(number);
4755 let _ = std::fs::remove_file(&path);
4756 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4757 }
4758
4759 pub fn prune_state(&self) -> Vec<String> {
4763 let base = self.root.join(STATE_DIR).join("state");
4764 let Ok(entries) = std::fs::read_dir(&base) else {
4765 return Vec::new();
4766 };
4767 let mut names: Vec<String> = entries
4768 .flatten()
4769 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4770 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4771 .collect();
4772 names.sort();
4773
4774 let mut removed = Vec::new();
4775 for name in names {
4776 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4777 continue;
4778 };
4779 if is_finished(&self.pr_state(number)) {
4780 let _ = std::fs::remove_file(base.join(&name));
4781 removed.push(format!("state {name}"));
4782 }
4783 }
4784 removed
4785 }
4786
4787 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4791 #[derive(Deserialize)]
4792 struct Row {
4793 number: i64,
4794 }
4795 let numbers = match numbers {
4796 Some(numbers) => numbers,
4797 None => {
4798 let listed: Result<Vec<i64>> = (|| {
4799 let text = self.gh(&[
4800 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4801 ])?;
4802 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4803 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4804 Ok(rows.into_iter().map(|row| row.number).collect())
4805 })();
4806 match self.record_failed_write(listed) {
4807 Ok(numbers) => numbers,
4808 Err(e) => {
4809 logdim!("could not inspect pull requests for state cleanup: {e}");
4810 return Vec::new();
4811 }
4812 }
4813 }
4814 };
4815
4816 let mut removed = Vec::new();
4817 for number in numbers {
4818 let state = match self.record_failed_write(self.try_pr_state(number)) {
4819 Ok(state) => state,
4820 Err(e) => {
4821 logdim!("could not inspect PR #{number} for state cleanup: {e}");
4822 continue;
4823 }
4824 };
4825 if !is_finished(&state) {
4826 continue;
4827 }
4828 let comments = match self.record_failed_write(self.try_state_comments(number)) {
4829 Ok(comments) => comments,
4830 Err(e) => {
4831 logdim!("could not inspect state comments on PR #{number}: {e}");
4832 continue;
4833 }
4834 };
4835 for (id, _) in comments {
4836 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4837 let deleted = self
4838 .gh(&["api", "-X", "DELETE", &path, "--silent"])
4839 .map(|_| ());
4840 match self.record_write(deleted) {
4841 Ok(()) => removed.push(format!("state comment on PR #{number}")),
4842 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
4843 }
4844 }
4845 }
4846 removed
4847 }
4848
4849 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
4856 let base = self.root.join(WORKTREE_DIR);
4857 let mut removed = Vec::new();
4858 let known = self.known_branches();
4859
4860 if let Ok(entries) = std::fs::read_dir(&base) {
4861 let mut names: Vec<String> = entries
4862 .flatten()
4863 .filter(|e| e.path().is_dir())
4864 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4865 .collect();
4866 names.sort();
4867
4868 for name in names {
4869 if let Some(rest) = name.strip_prefix("review-") {
4872 let number: i64 = rest.parse().unwrap_or(-1);
4873 if !(force_all || is_finished(&self.pr_state(number))) {
4874 continue;
4875 }
4876 let path = base.join(&name);
4877 if force_all {
4878 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4879 if !belongs {
4880 return Ok(false);
4881 }
4882 let local_ref = review_ref(number);
4883 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4884 return Ok(false);
4885 }
4886 let head = self.head_oid_checked(&path)?;
4887 let recorded = self
4888 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4889 .trim()
4890 .to_string();
4891 Ok(head == recorded)
4892 });
4893 match owned {
4894 Ok(true) => {}
4895 Ok(false) => {
4896 logdim!(
4897 "kept {} because no matching SPAR review reference proves \
4898 ownership",
4899 path.display()
4900 );
4901 continue;
4902 }
4903 Err(e) => {
4904 logdim!(
4905 "kept {} because review ownership could not be verified: {}",
4906 path.display(),
4907 e.last_line()
4908 );
4909 continue;
4910 }
4911 }
4912 } else {
4913 if let Err(e) = self.refuse_review_worktree_changes(number) {
4914 logdim!(
4915 "kept {} because its review state could not be verified as \
4916 disposable: {}",
4917 path.display(),
4918 e.last_line()
4919 );
4920 continue;
4921 }
4922 }
4923 if force_all {
4924 if self.remove_worktree_at_force(&path) {
4925 self.git_try(&["update-ref", "-d", &review_ref(number)]);
4926 }
4927 } else {
4928 self.release_review_worktree(number);
4929 }
4930 if !path.exists() {
4931 removed.push(name);
4932 }
4933 continue;
4934 }
4935 let branch = format!("{}{name}", self.branch_prefix);
4936 if !(force_all || self.worktree_is_done(&branch)) {
4937 continue;
4938 }
4939 if !known.contains_key(&branch) {
4940 logdim!("kept {branch} because it has no branch record");
4941 continue;
4942 }
4943 let path = base.join(&name);
4944 if !force_all {
4945 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
4946 Ok(true) => {
4947 logdim!(
4948 "kept {} because it contains uncommitted changes or ignored files",
4949 path.display()
4950 );
4951 continue;
4952 }
4953 Err(e) => {
4954 logdim!(
4955 "kept {} because its Git state could not be checked: {}",
4956 path.display(),
4957 e.last_line()
4958 );
4959 continue;
4960 }
4961 Ok(false) => {}
4962 }
4963 match self.branch_deletion_is_safe(&branch) {
4964 Ok(true) => {}
4965 Ok(false) => {
4966 logdim!(
4967 "kept {branch} because no surviving ref preserves its tip or \
4968 reflog-only commits"
4969 );
4970 continue;
4971 }
4972 Err(e) => {
4973 logdim!(
4974 "kept {branch} because preservation could not be verified: {}",
4975 e.last_line()
4976 );
4977 continue;
4978 }
4979 }
4980 }
4981 let removed_worktree = if force_all {
4982 self.remove_worktree_at_force(&path)
4983 } else {
4984 match self.remove_worktree_at(&path) {
4985 Ok(removed) => removed,
4986 Err(error) => {
4987 logdim!(
4988 "kept {branch} and {} because removal did not reach a confirmed \
4989 quiet point: {}",
4990 path.display(),
4991 error.last_line()
4992 );
4993 false
4994 }
4995 }
4996 };
4997 if !removed_worktree {
4998 continue;
4999 }
5000 if force_all {
5001 self.git_try(&["branch", "-D", &branch]);
5002 self.forget_branch(&branch);
5003 } else {
5004 match self.delete_branch_if_safe(&branch) {
5005 Ok(true) => self.forget_branch(&branch),
5006 Ok(false) => logdim!(
5007 "kept {branch} because its tip or reflog changed before deletion"
5008 ),
5009 Err(error) => logdim!(
5010 "kept {branch} because deletion safety could not be rechecked: {}",
5011 error.last_line()
5012 ),
5013 }
5014 }
5015 removed.push(name);
5016 }
5017 }
5018 removed.extend(self.prune_branches(force_all));
5019 removed
5020 }
5021
5022 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
5029 let known = self.known_branches();
5030 let branches: Vec<String> = known.keys().cloned().collect();
5031 if branches.is_empty() {
5032 return Vec::new();
5033 }
5034
5035 let checked_out: Vec<String> = self
5036 .git_try(&["worktree", "list", "--porcelain"])
5037 .lines()
5038 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
5039 .collect();
5040
5041 let existing: Vec<String> = self
5044 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
5045 .lines()
5046 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
5047 .collect();
5048
5049 let mut removed = Vec::new();
5050 for branch in branches {
5051 if !existing.contains(&branch) {
5052 self.forget_branch(&branch); continue;
5054 }
5055 if checked_out.contains(&branch) {
5056 continue;
5057 }
5058 if !(force_all || self.worktree_is_done(&branch)) {
5059 continue;
5060 }
5061 if !force_all {
5062 let Some(_record) = known.get(&branch) else {
5063 continue;
5064 };
5065 match self.branch_deletion_is_safe(&branch) {
5066 Ok(true) => {}
5067 Ok(false) => {
5068 logdim!(
5069 "kept {branch} because no surviving ref preserves its tip or \
5070 reflog-only commits"
5071 );
5072 continue;
5073 }
5074 Err(e) => {
5075 logdim!(
5076 "kept {branch} because preservation could not be verified: {}",
5077 e.last_line()
5078 );
5079 continue;
5080 }
5081 }
5082 }
5083 let deleted = if force_all {
5084 self.git(&["branch", "-D", &branch]).map(|_| true)
5085 } else {
5086 self.delete_branch_if_safe(&branch)
5087 };
5088 match deleted {
5089 Ok(true) => {
5090 self.forget_branch(&branch);
5091 removed.push(format!("branch {branch}"));
5092 }
5093 Ok(false) => {
5094 logdim!("kept {branch} because its tip or reflog changed before deletion");
5095 }
5096 Err(e) => {
5097 logdim!("could not delete {branch}: {}", e.last_line());
5100 }
5101 }
5102 }
5103 removed
5104 }
5105
5106 fn worktree_is_done(&self, branch: &str) -> bool {
5108 #[derive(Deserialize)]
5109 struct Row {
5110 state: String,
5111 }
5112 let entry = branch
5113 .strip_prefix(self.branch_prefix.as_str())
5114 .unwrap_or(branch);
5115 if let Some(rest) = entry.strip_prefix("pr-") {
5116 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
5117 }
5118 if entry.starts_with("issue-") || entry.starts_with("split-") {
5122 let text = self.gh_try(&[
5123 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
5124 ]);
5125 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5126 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
5127 }
5128 false
5129 }
5130}
5131
5132pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5144 match probe() {
5145 Ok(value) => Ok(value),
5146 Err(first) => {
5147 logdim!("retrying a worktree probe after: {}", first.last_line());
5148 probe()
5149 }
5150 }
5151}
5152
5153pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5159 let root = std::fs::canonicalize(cwd)
5160 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5161 let mut files = BTreeMap::new();
5162 let mut visited = BTreeSet::new();
5163 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5164 Ok(AttributeState { files })
5165}
5166
5167fn collect_attribute_files(
5168 root: &Path,
5169 repository: &Path,
5170 prefix: &Path,
5171 visited: &mut BTreeSet<PathBuf>,
5172 files: &mut BTreeMap<PathBuf, [u8; 32]>,
5173) -> Result<()> {
5174 let canonical = std::fs::canonicalize(repository)
5175 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5176 if !visited.insert(canonical) {
5177 bail!("submodule recursion revisited {}", repository.display());
5178 }
5179 let entries = index_entries(repository)?;
5180 let mut paths: BTreeSet<PathBuf> = entries
5181 .iter()
5182 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5183 .map(|entry| entry.path.clone())
5184 .collect();
5185 let untracked = run_git_bytes(
5191 repository,
5192 &[
5193 "ls-files",
5194 "--others",
5195 "--exclude-standard",
5196 "-z",
5197 "--",
5198 ".gitattributes",
5199 ":(glob)**/.gitattributes",
5200 ],
5201 )?;
5202 if !untracked.is_empty() && !untracked.ends_with(&[0]) {
5203 bail!(
5204 "git returned an unterminated attribute-file listing for {}",
5205 repository.display()
5206 );
5207 }
5208 for raw in untracked
5209 .split(|byte| *byte == 0)
5210 .filter(|record| !record.is_empty())
5211 {
5212 paths.insert(safe_git_path(raw, "attribute")?);
5213 }
5214 for path in paths {
5215 let from_root = prefix.join(&path);
5216 let state = attribute_file_fingerprint(&root.join(&from_root))?;
5217 files.insert(from_root, state);
5218 }
5219 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5220 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5221 continue;
5222 };
5223 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5224 }
5225 Ok(())
5226}
5227
5228pub(crate) fn uncertain_worktree_change(
5231 cwd: &Path,
5232 message: impl Into<String>,
5233) -> crate::error::SparError {
5234 let message = message.into();
5235 let marker = write_recovery_marker(cwd, &message);
5236 let note = match marker {
5237 Ok(path) => format!(" Recovery marker: {}.", path.display()),
5238 Err(e) => format!(
5239 " A recovery marker could not be written: {}.",
5240 e.last_line()
5241 ),
5242 };
5243 crate::error::SparError::uncertain_write(format!("{message}{note}"))
5244}
5245
5246fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5247 use std::sync::atomic::{AtomicU32, Ordering};
5248 static NEXT: AtomicU32 = AtomicU32::new(0);
5249 for _ in 0..1000 {
5250 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5251 let path = cwd.join(format!(
5252 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5253 std::process::id()
5254 ));
5255 let mut options = OpenOptions::new();
5256 options.write(true).create_new(true);
5257 #[cfg(unix)]
5258 {
5259 use std::os::unix::fs::OpenOptionsExt;
5260 options.mode(0o600);
5261 }
5262 match options.open(&path) {
5263 Ok(mut file) => {
5264 file.write_all(detail.as_bytes())
5265 .and_then(|_| file.write_all(b"\n"))
5266 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5267 return Ok(path);
5268 }
5269 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5270 Err(e) => {
5271 return Err(spar_err!(
5272 "could not create a recovery marker in {}: {e}",
5273 cwd.display()
5274 ))
5275 }
5276 }
5277 }
5278 bail!(
5279 "could not choose a free recovery marker name in {}",
5280 cwd.display()
5281 )
5282}
5283
5284fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5289 let mut argv = vec![
5290 "git".to_string(),
5291 "-c".to_string(),
5292 "maintenance.auto=false".to_string(),
5293 "-c".to_string(),
5294 "gc.auto=0".to_string(),
5295 ];
5296 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5297 argv
5298}
5299
5300fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5301 let mut argv = git_without_maintenance_argv(&[]);
5302 argv.extend([
5303 "-c".to_string(),
5304 "core.fsmonitor=".to_string(),
5305 "-c".to_string(),
5306 "commit.gpgsign=false".to_string(),
5307 "-c".to_string(),
5308 "core.hooksPath=/dev/null".to_string(),
5309 ]);
5310 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5311 argv
5312}
5313
5314pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5321 let root = std::fs::canonicalize(cwd)
5322 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5323 let mut files = BTreeMap::new();
5324 let mut ignored = BTreeSet::new();
5325 let mut visited = BTreeSet::new();
5326 collect_untracked_files(
5327 &root,
5328 &root,
5329 Path::new(""),
5330 &mut visited,
5331 &mut files,
5332 &mut ignored,
5333 )?;
5334 Ok(IgnoredState { files, ignored })
5335}
5336
5337fn collect_untracked_files(
5338 root: &Path,
5339 repository: &Path,
5340 prefix: &Path,
5341 visited: &mut BTreeSet<PathBuf>,
5342 files: &mut BTreeMap<PathBuf, UntrackedFile>,
5343 ignored: &mut BTreeSet<PathBuf>,
5344) -> Result<()> {
5345 let canonical = std::fs::canonicalize(repository)
5346 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5347 if !visited.insert(canonical.clone()) {
5348 bail!("submodule recursion revisited {}", canonical.display());
5349 }
5350 let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
5351 if !listed.is_empty() && !listed.ends_with(&[0]) {
5352 bail!(
5353 "git returned an unterminated untracked-file list for {}",
5354 repository.display()
5355 );
5356 }
5357
5358 for raw in listed
5359 .split(|byte| *byte == 0)
5360 .filter(|raw| !raw.is_empty())
5361 {
5362 let (relative, nested) = untracked_record(raw, "untracked")?;
5363 let from_root = prefix.join(&relative);
5364 if is_spar_own_path(&from_root) {
5365 continue;
5366 }
5367 let absolute = root.join(&from_root);
5368 let fingerprint = if nested {
5369 nested_repository_fingerprint(&absolute)?
5370 } else {
5371 ignored_file_fingerprint(&absolute)?
5372 };
5373 if files.insert(from_root.clone(), fingerprint).is_some() {
5374 bail!(
5375 "git returned the untracked path more than once: {:?}",
5376 from_root
5377 );
5378 }
5379 }
5380
5381 let ignored_listed = run_git_bytes(
5382 repository,
5383 &[
5384 "ls-files",
5385 "--others",
5386 "--ignored",
5387 "--exclude-standard",
5388 "-z",
5389 ],
5390 )?;
5391 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5392 bail!(
5393 "git returned an unterminated ignored-file list for {}",
5394 repository.display()
5395 );
5396 }
5397 for raw in ignored_listed
5403 .split(|byte| *byte == 0)
5404 .filter(|raw| !raw.is_empty())
5405 {
5406 let (relative, nested) = untracked_record(raw, "ignored")?;
5407 let from_root = prefix.join(relative);
5408 if is_spar_own_path(&from_root) {
5409 continue;
5410 }
5411 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5412 let absolute = root.join(&from_root);
5413 let fingerprint = if nested {
5414 nested_repository_fingerprint(&absolute)?
5415 } else {
5416 ignored_file_fingerprint(&absolute)?
5417 };
5418 slot.insert(fingerprint);
5419 }
5420 if !ignored.insert(from_root.clone()) {
5421 bail!(
5422 "git returned the ignored path more than once: {:?}",
5423 from_root
5424 );
5425 }
5426 }
5427
5428 for link in gitlinks(repository)? {
5429 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5430 continue;
5431 };
5432 collect_untracked_files(
5433 root,
5434 &submodule,
5435 &prefix.join(&link.path),
5436 visited,
5437 files,
5438 ignored,
5439 )?;
5440 }
5441 Ok(())
5442}
5443
5444fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5445 let argv = git_without_automation_argv(args);
5446 proc::run_bytes(
5447 &argv,
5448 &ExecOpts::new()
5449 .cwd(cwd)
5450 .timeout_secs(30)
5451 .stop_descendants(true),
5452 )
5453}
5454
5455fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5456 let argv = git_without_automation_argv(args);
5457 proc::run(
5458 &argv,
5459 &ExecOpts::new()
5460 .cwd(cwd)
5461 .timeout_secs(30)
5462 .stop_descendants(true),
5463 )
5464}
5465
5466fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5467 let path = path.to_str().ok_or_else(|| {
5468 spar_err!(
5469 "cannot verify filtered content for a non-UTF-8 path in {}",
5470 cwd.display()
5471 )
5472 })?;
5473 let path_arg = format!("--path={path}");
5474 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5475 Ok(Sha256::digest(bytes).into())
5476}
5477
5478fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5479 let relative = path_from_git_bytes(raw)?;
5480 if relative.is_absolute()
5481 || relative.components().any(|component| {
5482 matches!(
5483 component,
5484 std::path::Component::ParentDir
5485 | std::path::Component::RootDir
5486 | std::path::Component::Prefix(_)
5487 )
5488 })
5489 {
5490 bail!("git returned an unsafe {kind} path: {:?}", relative);
5491 }
5492 Ok(relative)
5493}
5494
5495fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5504 let nested = raw.last() == Some(&b'/');
5505 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5506 if trimmed.is_empty() {
5507 bail!("git returned an empty {kind} path");
5508 }
5509 Ok((safe_git_path(trimmed, kind)?, nested))
5510}
5511
5512fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5513 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5514 if !listed.is_empty() && !listed.ends_with(&[0]) {
5515 bail!(
5516 "git returned an unterminated index listing for {}",
5517 cwd.display()
5518 );
5519 }
5520 let mut entries = Vec::new();
5521 for record in listed
5522 .split(|byte| *byte == 0)
5523 .filter(|record| !record.is_empty())
5524 {
5525 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5526 bail!(
5527 "git returned a malformed index record for {}",
5528 cwd.display()
5529 );
5530 };
5531 let header = &record[..tab];
5532 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5533 if fields.len() != 3 {
5534 bail!(
5535 "git returned a malformed index header for {}",
5536 cwd.display()
5537 );
5538 }
5539 if fields[2] != b"0" {
5540 continue;
5541 }
5542 let mode = std::str::from_utf8(fields[0])
5543 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5544 .to_string();
5545 let oid = std::str::from_utf8(fields[1])
5546 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5547 .to_string();
5548 entries.push(IndexEntry {
5549 path: safe_git_path(&record[tab + 1..], "index")?,
5550 mode,
5551 oid,
5552 });
5553 }
5554 Ok(entries)
5555}
5556
5557fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5558 let untracked = run_git_bytes(
5559 cwd,
5560 &[
5561 "ls-files",
5562 "--others",
5563 "-z",
5564 "--",
5565 ".gitattributes",
5566 ":(glob)**/.gitattributes",
5567 ],
5568 )?;
5569 if !untracked.is_empty() {
5570 return Ok(true);
5571 }
5572
5573 let index = index_entries(cwd)?
5574 .into_iter()
5575 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5576 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5577 .collect::<BTreeMap<_, _>>();
5578 let head = tree_entries(cwd, "HEAD")?
5579 .into_iter()
5580 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5581 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5582 .collect::<BTreeMap<_, _>>();
5583 if index != head {
5584 return Ok(true);
5585 }
5586
5587 let effective = check_attributes(cwd, index.keys().cloned())?;
5588 let config = CheckoutConfig::read(cwd)?;
5589 for (path, (_mode, oid)) in index {
5590 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5591 return Ok(true);
5592 };
5593 let attributes = effective
5594 .get(&path)
5595 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5596 if allows_expected_crlf(&config, attributes)? {
5597 if worktree.mode == "120000" {
5598 return Ok(true);
5599 }
5600 let (normalized, every_lf_was_crlf) =
5601 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5602 if !every_lf_was_crlf || normalized != oid {
5603 return Ok(true);
5604 }
5605 } else if worktree.raw_oid != oid {
5606 return Ok(true);
5607 }
5608 }
5609 Ok(false)
5610}
5611
5612fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5613 Ok(index_entries(cwd)?
5614 .into_iter()
5615 .filter(|entry| entry.mode == "160000")
5616 .map(|entry| Gitlink {
5617 path: entry.path,
5618 oid: entry.oid,
5619 })
5620 .collect())
5621}
5622
5623fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5624 let mut tracked = BTreeMap::new();
5625 for entry in index_entries(cwd)? {
5626 if entry.mode == "160000" {
5627 continue;
5628 }
5629 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5630 tracked.insert(
5631 entry.path,
5632 TrackedEntry {
5633 index_mode: entry.mode,
5634 index_oid: entry.oid,
5635 worktree,
5636 },
5637 );
5638 }
5639 Ok(tracked)
5640}
5641
5642fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5643 let metadata = match std::fs::symlink_metadata(path) {
5644 Ok(metadata) => metadata,
5645 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5646 Err(e) => {
5647 return Err(spar_err!(
5648 "could not inspect tracked file {}: {e}",
5649 path.display()
5650 ))
5651 }
5652 };
5653 let mut fingerprint = Sha256::new();
5654 if metadata.file_type().is_symlink() {
5655 let target = std::fs::read_link(path)
5656 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5657 let bytes = os_str_bytes(target.as_os_str())?;
5658 fingerprint.update(b"symlink\0");
5659 fingerprint.update(&bytes);
5660 let content = Sha256::digest(&bytes).into();
5661 return Ok(Some(WorktreeFile {
5662 mode: "120000".to_string(),
5663 #[cfg(unix)]
5664 permissions: 0,
5665 raw_oid: git_blob_oid(oid_len, &bytes)?,
5666 fingerprint: fingerprint.finalize().into(),
5667 content,
5668 }));
5669 }
5670 if !metadata.is_file() {
5671 bail!("tracked path {} is not a file or symlink", path.display());
5672 }
5673
5674 let mut options = OpenOptions::new();
5675 options.read(true);
5676 #[cfg(unix)]
5677 {
5678 use std::os::unix::fs::OpenOptionsExt;
5679 options.custom_flags(libc::O_NOFOLLOW);
5680 }
5681 let mut file = options
5682 .open(path)
5683 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5684 let before = file
5685 .metadata()
5686 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5687 let mode = tracked_file_mode(&before);
5688 #[cfg(unix)]
5689 let permissions = {
5690 use std::os::unix::fs::MetadataExt;
5691 before.mode() & 0o7777
5692 };
5693 fingerprint.update(b"file\0");
5694 fingerprint.update(mode.as_bytes());
5695 #[cfg(unix)]
5696 fingerprint.update(permissions.to_le_bytes());
5697 fingerprint.update(before.len().to_le_bytes());
5698 let mut content = Sha256::new();
5699 let header = format!("blob {}\0", before.len());
5700 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5701 let mut buf = [0u8; 64 * 1024];
5702 loop {
5703 let read = file
5704 .read(&mut buf)
5705 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5706 if read == 0 {
5707 break;
5708 }
5709 fingerprint.update(&buf[..read]);
5710 content.update(&buf[..read]);
5711 object.update(&buf[..read]);
5712 }
5713 let after = file
5714 .metadata()
5715 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5716 if before.len() != after.len()
5717 || before.modified().ok() != after.modified().ok()
5718 || before.permissions() != after.permissions()
5719 {
5720 bail!(
5721 "tracked file {} changed while it was being inspected",
5722 path.display()
5723 );
5724 }
5725 let current = std::fs::symlink_metadata(path)
5726 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5727 if !same_file(&after, ¤t) {
5728 bail!(
5729 "tracked file {} was replaced while it was being inspected",
5730 path.display()
5731 );
5732 }
5733 Ok(Some(WorktreeFile {
5734 mode,
5735 #[cfg(unix)]
5736 permissions,
5737 raw_oid: object.finish(),
5738 fingerprint: fingerprint.finalize().into(),
5739 content: content.finalize().into(),
5740 }))
5741}
5742
5743fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5744 let metadata = std::fs::symlink_metadata(path)
5745 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5746 let mut digest = Sha256::new();
5747 if metadata.file_type().is_symlink() {
5748 digest.update(b"symlink\0");
5749 let target = std::fs::read_link(path)
5750 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5751 digest.update(os_str_bytes(target.as_os_str())?);
5752 return Ok(digest.finalize().into());
5753 }
5754 if !metadata.is_file() {
5755 bail!("attribute path {} is not a file or symlink", path.display());
5756 }
5757 let mut options = OpenOptions::new();
5758 options.read(true);
5759 #[cfg(unix)]
5760 {
5761 use std::os::unix::fs::OpenOptionsExt;
5762 options.custom_flags(libc::O_NOFOLLOW);
5763 }
5764 let mut file = options
5765 .open(path)
5766 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5767 let before = file
5768 .metadata()
5769 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5770 digest.update(b"file\0");
5771 let mut buf = [0u8; 64 * 1024];
5772 loop {
5773 let read = file
5774 .read(&mut buf)
5775 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5776 if read == 0 {
5777 break;
5778 }
5779 digest.update(&buf[..read]);
5780 }
5781 let after = file
5782 .metadata()
5783 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5784 let current = std::fs::symlink_metadata(path)
5785 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5786 if before.len() != after.len()
5787 || before.modified().ok() != after.modified().ok()
5788 || !same_file(&after, ¤t)
5789 {
5790 bail!(
5791 "attribute file {} changed while it was being inspected",
5792 path.display()
5793 );
5794 }
5795 Ok(digest.finalize().into())
5796}
5797
5798enum ObjectHasher {
5799 Sha1(Sha1),
5800 Sha256(Sha256),
5801}
5802
5803impl ObjectHasher {
5804 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5805 let mut hasher = match oid_len {
5806 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5807 64 => Self::Sha256(Sha256::new()),
5808 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5809 };
5810 hasher.update(header);
5811 Ok(hasher)
5812 }
5813
5814 fn update(&mut self, bytes: &[u8]) {
5815 match self {
5816 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5817 Self::Sha256(hasher) => hasher.update(bytes),
5818 }
5819 }
5820
5821 fn finish(self) -> String {
5822 let bytes = match self {
5823 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
5824 Self::Sha256(hasher) => hasher.finalize().to_vec(),
5825 };
5826 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
5827 }
5828}
5829
5830fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
5831 let header = format!("blob {}\0", bytes.len());
5832 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
5833 hasher.update(bytes);
5834 Ok(hasher.finish())
5835}
5836
5837fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
5838 let mut first = open_regular_file(path)?;
5839 let first_before = first
5840 .metadata()
5841 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5842 let mut raw_len = 0u64;
5843 let mut crlf_pairs = 0u64;
5844 let mut previous_was_cr = false;
5845 let mut every_lf_was_crlf = true;
5846 let mut buf = [0u8; 64 * 1024];
5847 loop {
5848 let read = first
5849 .read(&mut buf)
5850 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5851 if read == 0 {
5852 break;
5853 }
5854 raw_len = raw_len
5855 .checked_add(read as u64)
5856 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5857 for byte in &buf[..read] {
5858 if *byte == b'\n' {
5859 if previous_was_cr {
5860 crlf_pairs += 1;
5861 } else {
5862 every_lf_was_crlf = false;
5863 }
5864 }
5865 previous_was_cr = *byte == b'\r';
5866 }
5867 }
5868 let first_after = first
5869 .metadata()
5870 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5871 let current = std::fs::symlink_metadata(path)
5872 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5873 if raw_len != first_before.len()
5874 || !stable_file_metadata(&first_before, &first_after)
5875 || !stable_file_metadata(&first_after, ¤t)
5876 {
5877 bail!(
5878 "tracked file {} changed while line endings were inspected",
5879 path.display()
5880 );
5881 }
5882
5883 let normalized_len = raw_len
5884 .checked_sub(crlf_pairs)
5885 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5886 let header = format!("blob {normalized_len}\0");
5887 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5888 let mut second = open_regular_file(path)?;
5889 let second_before = second
5890 .metadata()
5891 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5892 if !stable_file_metadata(&first_after, &second_before) {
5893 bail!(
5894 "tracked file {} changed between line-ending checks",
5895 path.display()
5896 );
5897 }
5898 let mut pending_cr = false;
5899 loop {
5900 let read = second
5901 .read(&mut buf)
5902 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5903 if read == 0 {
5904 break;
5905 }
5906 for byte in &buf[..read] {
5907 if pending_cr {
5908 if *byte == b'\n' {
5909 object.update(b"\n");
5910 pending_cr = false;
5911 continue;
5912 }
5913 object.update(b"\r");
5914 pending_cr = false;
5915 }
5916 if *byte == b'\r' {
5917 pending_cr = true;
5918 } else {
5919 object.update(std::slice::from_ref(byte));
5920 }
5921 }
5922 }
5923 if pending_cr {
5924 object.update(b"\r");
5925 }
5926 let second_after = second
5927 .metadata()
5928 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5929 let current = std::fs::symlink_metadata(path)
5930 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5931 if !stable_file_metadata(&second_before, &second_after)
5932 || !stable_file_metadata(&second_after, ¤t)
5933 {
5934 bail!(
5935 "tracked file {} changed while line endings were hashed",
5936 path.display()
5937 );
5938 }
5939 Ok((object.finish(), every_lf_was_crlf))
5940}
5941
5942fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5943 let mut options = OpenOptions::new();
5944 options.read(true);
5945 #[cfg(unix)]
5946 {
5947 use std::os::unix::fs::OpenOptionsExt;
5948 options.custom_flags(libc::O_NOFOLLOW);
5949 }
5950 let file = options
5951 .open(path)
5952 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5953 let metadata = file
5954 .metadata()
5955 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5956 if !metadata.is_file() {
5957 bail!("tracked path {} is not a regular file", path.display());
5958 }
5959 Ok(file)
5960}
5961
5962fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5963 if !same_file(left, right)
5964 || left.len() != right.len()
5965 || left.modified().ok() != right.modified().ok()
5966 || left.permissions() != right.permissions()
5967 {
5968 return false;
5969 }
5970 #[cfg(unix)]
5971 {
5972 use std::os::unix::fs::MetadataExt;
5973 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5974 }
5975 #[cfg(not(unix))]
5976 {
5977 left.created().ok() == right.created().ok()
5978 }
5979}
5980
5981const ATTRIBUTE_BATCH: usize = 1000;
5988
5989fn check_attributes(
5990 cwd: &Path,
5991 paths: impl IntoIterator<Item = PathBuf>,
5992) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5993 let paths = paths.into_iter().collect::<BTreeSet<_>>();
5994 if paths.is_empty() {
5995 return Ok(BTreeMap::new());
5996 }
5997 let ordered = paths.into_iter().collect::<Vec<_>>();
5998 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5999 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
6000 values.extend(check_attribute_batch(cwd, batch)?);
6001 }
6002 Ok(values)
6003}
6004
6005fn check_attribute_batch(
6006 cwd: &Path,
6007 batch: &[PathBuf],
6008) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6009 const NAMES: [&str; 6] = [
6010 "filter",
6011 "working-tree-encoding",
6012 "ident",
6013 "text",
6014 "eol",
6015 "crlf",
6016 ];
6017 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
6018 let mut input = String::new();
6019 for path in &paths {
6020 let path = path.to_str().ok_or_else(|| {
6021 spar_err!(
6022 "cannot inspect attributes for a non-UTF-8 path in {}",
6023 cwd.display()
6024 )
6025 })?;
6026 input.push_str(path);
6027 input.push('\0');
6028 }
6029 let argv = git_without_automation_argv(&[
6030 "check-attr",
6031 "-z",
6032 "--cached",
6033 "--stdin",
6034 "filter",
6035 "working-tree-encoding",
6036 "ident",
6037 "text",
6038 "eol",
6039 "crlf",
6040 ]);
6041 let output = proc::run_bytes(
6042 &argv,
6043 &ExecOpts::new()
6044 .cwd(cwd)
6045 .timeout_secs(30)
6046 .stdin(input)
6047 .stop_descendants(true),
6048 )?;
6049 if !output.is_empty() && !output.ends_with(&[0]) {
6050 bail!(
6051 "git returned an unterminated attribute result for {}",
6052 cwd.display()
6053 );
6054 }
6055 let fields = output
6056 .split(|byte| *byte == 0)
6057 .filter(|field| !field.is_empty())
6058 .collect::<Vec<_>>();
6059 if fields.len() != paths.len() * NAMES.len() * 3 {
6060 bail!(
6061 "git returned an unexpected attribute result for {}",
6062 cwd.display()
6063 );
6064 }
6065 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6066 for record in fields.chunks_exact(3) {
6067 let path = safe_git_path(record[0], "attribute")?;
6068 if !paths.contains(&path) {
6069 bail!(
6070 "git returned attributes for the wrong path in {}",
6071 cwd.display()
6072 );
6073 }
6074 let name = std::str::from_utf8(record[1])
6075 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
6076 let value = std::str::from_utf8(record[2])
6077 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
6078 values
6079 .entry(path)
6080 .or_default()
6081 .insert(name.to_string(), value.to_string());
6082 }
6083 if paths.iter().any(|path| {
6084 values
6085 .get(path)
6086 .is_none_or(|attributes| attributes.len() != NAMES.len())
6087 }) {
6088 bail!(
6089 "git omitted an attribute result for a tracked path in {}",
6090 cwd.display()
6091 );
6092 }
6093 Ok(values)
6094}
6095
6096fn attribute_is_active(value: Option<&String>) -> bool {
6097 !matches!(
6098 value.map(String::as_str),
6099 None | Some("unspecified") | Some("unset")
6100 )
6101}
6102
6103fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
6104 attribute_is_active(values.get("filter"))
6105 || attribute_is_active(values.get("working-tree-encoding"))
6106}
6107
6108struct CheckoutConfig {
6117 autocrlf: Option<String>,
6118 eol: Option<String>,
6119 symlinks: Option<bool>,
6120}
6121
6122impl CheckoutConfig {
6123 fn read(cwd: &Path) -> Result<Self> {
6124 Ok(Self {
6125 autocrlf: git_config_value(cwd, "core.autocrlf")?,
6126 eol: git_config_value(cwd, "core.eol")?,
6127 symlinks: git_config_bool(cwd, "core.symlinks")?,
6128 })
6129 }
6130
6131 fn autocrlf_is_true(&self) -> bool {
6132 self.autocrlf.as_ref().is_some_and(|value| {
6133 matches!(
6134 value.to_ascii_lowercase().as_str(),
6135 "true" | "yes" | "on" | "1"
6136 )
6137 })
6138 }
6139
6140 fn eol_is(&self, wanted: &str) -> bool {
6141 self.eol
6142 .as_ref()
6143 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6144 }
6145}
6146
6147fn path_has_ambiguous_transform(
6148 config: &CheckoutConfig,
6149 values: &BTreeMap<String, String>,
6150) -> Result<bool> {
6151 if path_has_external_transform(values)
6152 || attribute_is_active(values.get("ident"))
6153 || attribute_is_active(values.get("crlf"))
6154 {
6155 return Ok(true);
6156 }
6157 let text = values.get("text").map(String::as_str);
6158 let eol = values.get("eol").map(String::as_str);
6159 if text == Some("auto") {
6160 return Ok(true);
6161 }
6162 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6163 || !matches!(
6164 eol,
6165 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6166 )
6167 {
6168 return Ok(true);
6169 }
6170 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6171 return Ok(config.autocrlf_is_true());
6172 }
6173 Ok(false)
6174}
6175
6176fn allows_expected_crlf(
6177 config: &CheckoutConfig,
6178 values: &BTreeMap<String, String>,
6179) -> Result<bool> {
6180 if path_has_external_transform(values)
6181 || attribute_is_active(values.get("ident"))
6182 || attribute_is_active(values.get("crlf"))
6183 {
6184 return Ok(false);
6185 }
6186 let text = values.get("text").map(String::as_str);
6187 let eol = values.get("eol").map(String::as_str);
6188 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6189 return Ok(false);
6190 }
6191 if eol == Some("crlf") {
6192 return Ok(true);
6193 }
6194 if text != Some("set") {
6195 return Ok(false);
6196 }
6197 if let Some(autocrlf) = config.autocrlf.as_deref() {
6198 match autocrlf.to_ascii_lowercase().as_str() {
6199 "true" | "yes" | "on" | "1" => return Ok(true),
6200 "input" => return Ok(false),
6201 _ => {}
6202 }
6203 }
6204 if config.eol_is("crlf") {
6205 return Ok(true);
6206 }
6207 #[cfg(windows)]
6208 if config.eol.is_none() || config.eol_is("native") {
6209 return Ok(true);
6210 }
6211 Ok(false)
6212}
6213
6214fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6215 let argv = git_without_automation_argv(&["config", "--get", key]);
6216 let output = proc::exec(
6217 &argv,
6218 &ExecOpts::new()
6219 .cwd(cwd)
6220 .timeout_secs(30)
6221 .check(false)
6222 .stop_descendants(true),
6223 )?;
6224 match output.code {
6225 0 => Ok(Some(output.stdout.trim().to_string())),
6226 1 => Ok(None),
6227 _ => bail!(
6228 "could not read Git configuration in {}: {}",
6229 cwd.display(),
6230 output.stderr.trim()
6231 ),
6232 }
6233}
6234
6235fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6236 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6237 let output = proc::exec(
6238 &argv,
6239 &ExecOpts::new()
6240 .cwd(cwd)
6241 .timeout_secs(30)
6242 .check(false)
6243 .stop_descendants(true),
6244 )?;
6245 match output.code {
6246 0 if output.stdout.trim() == "true" => Ok(Some(true)),
6247 0 if output.stdout.trim() == "false" => Ok(Some(false)),
6248 0 => bail!(
6249 "git returned an invalid boolean for {key} in {}",
6250 cwd.display()
6251 ),
6252 1 => Ok(None),
6253 _ => bail!(
6254 "could not read Git configuration in {}: {}",
6255 cwd.display(),
6256 output.stderr.trim()
6257 ),
6258 }
6259}
6260
6261#[cfg(unix)]
6262fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6263 use std::os::unix::fs::PermissionsExt;
6264 if metadata.permissions().mode() & 0o111 == 0 {
6265 "100644".to_string()
6266 } else {
6267 "100755".to_string()
6268 }
6269}
6270
6271#[cfg(not(unix))]
6272fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6273 "100644".to_string()
6274}
6275
6276fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6277 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6278 if !listed.is_empty() && !listed.ends_with(&[0]) {
6279 bail!(
6280 "git returned an unterminated tree listing for {}",
6281 cwd.display()
6282 );
6283 }
6284 let mut entries = Vec::new();
6285 for record in listed
6286 .split(|byte| *byte == 0)
6287 .filter(|record| !record.is_empty())
6288 {
6289 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6290 bail!("git returned a malformed tree record for {}", cwd.display());
6291 };
6292 let fields = record[..tab]
6293 .split(|byte| *byte == b' ')
6294 .collect::<Vec<_>>();
6295 if fields.len() != 3 {
6296 bail!("git returned a malformed tree header for {}", cwd.display());
6297 }
6298 let mode = std::str::from_utf8(fields[0])
6299 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6300 .to_string();
6301 let oid = std::str::from_utf8(fields[2])
6302 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6303 .to_string();
6304 entries.push(IndexEntry {
6305 path: safe_git_path(&record[tab + 1..], "tree")?,
6306 mode,
6307 oid,
6308 });
6309 }
6310 Ok(entries)
6311}
6312
6313fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6314 Ok(tree_entries(cwd, "HEAD")?
6315 .into_iter()
6316 .filter(|entry| entry.mode == "160000")
6317 .map(|entry| (entry.path, entry.oid))
6318 .collect())
6319}
6320
6321fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6322 let head = head_gitlinks(cwd)?;
6323 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6324 .into_iter()
6325 .map(|link| (link.path, link.oid))
6326 .collect();
6327 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6328 paths.extend(index.keys().cloned());
6329 Ok(paths
6330 .into_iter()
6331 .filter(|path| head.get(path) != index.get(path))
6332 .collect())
6333}
6334
6335fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6336 let path = parent.join(relative);
6337 let metadata = match std::fs::symlink_metadata(&path) {
6338 Ok(metadata) => metadata,
6339 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6340 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6341 };
6342 if !metadata.is_dir() {
6343 bail!("the gitlink at {} is not a directory", path.display());
6344 }
6345 let canonical = std::fs::canonicalize(&path)
6346 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6347 if canonical != path {
6348 bail!(
6349 "the gitlink at {} resolves through a symlink",
6350 path.display()
6351 );
6352 }
6353 if !path.join(".git").exists() {
6354 let empty = std::fs::read_dir(&path)
6355 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6356 .next()
6357 .is_none();
6358 if empty {
6359 return Ok(None);
6360 }
6361 bail!(
6362 "the uninitialized gitlink at {} contains local files",
6363 path.display()
6364 );
6365 }
6366 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6367 if inside.trim() != "true" {
6368 bail!("the gitlink at {} is not a worktree", path.display());
6369 }
6370 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6371 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6372 spar_err!(
6373 "could not resolve the gitlink top level at {}: {e}",
6374 path.display()
6375 )
6376 })?;
6377 if top != canonical {
6378 bail!(
6379 "the gitlink at {} belongs to a different worktree",
6380 path.display()
6381 );
6382 }
6383 Ok(Some(canonical))
6384}
6385
6386fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6400 let root = std::fs::canonicalize(cwd)
6401 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6402 let mut allowed = BTreeSet::from([root.join(".git")]);
6403 let mut checkouts = BTreeSet::new();
6404 let mut repositories = vec![root.clone()];
6405 let mut visited = BTreeSet::new();
6406 while let Some(repository) = repositories.pop() {
6407 let canonical = std::fs::canonicalize(&repository)
6408 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6409 if !visited.insert(canonical.clone()) {
6410 bail!("submodule recursion revisited {}", canonical.display());
6411 }
6412 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6413 let (path, nested) = untracked_record(&raw, "untracked")?;
6414 if nested {
6415 checkouts.insert(canonical.join(path));
6416 }
6417 }
6418 for link in gitlinks(&canonical)? {
6419 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6420 continue;
6421 };
6422 allowed.insert(submodule.join(".git"));
6423 repositories.push(submodule);
6424 }
6425 }
6426
6427 let scan_root = root.clone();
6431 let mut directories = vec![root];
6432 while let Some(directory) = directories.pop() {
6433 let entries = match std::fs::read_dir(&directory) {
6434 Ok(entries) => entries,
6435 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6436 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6437 };
6438 for entry in entries {
6439 let entry = match entry {
6440 Ok(entry) => entry,
6441 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6442 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6443 };
6444 let path = entry.path();
6445 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6446 continue;
6447 }
6448 if entry.file_name() == OsStr::new(".git") {
6449 if !allowed.contains(&path) {
6450 return Ok(Some(path));
6451 }
6452 continue;
6453 }
6454 let kind = match entry.file_type() {
6455 Ok(kind) => kind,
6456 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6457 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6458 };
6459 if kind.is_dir() && !checkouts.contains(&path) {
6460 directories.push(path);
6461 }
6462 }
6463 }
6464 Ok(None)
6465}
6466
6467pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6468 if let Some(path) = unexpected_nested_git_entry(cwd)? {
6469 bail!(
6470 "the worktree contains a Git entry at {} that Git does not report as a nested \
6471 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6472 prove they are unchanged.",
6473 path.display()
6474 );
6475 }
6476 let root = std::fs::canonicalize(cwd)
6477 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6478 let mut repositories = BTreeMap::new();
6479 let mut visited = BTreeSet::new();
6480 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
6481 Ok(GitState { repositories })
6482}
6483
6484fn collect_git_state(
6485 repository: &Path,
6486 prefix: &Path,
6487 visited: &mut BTreeSet<PathBuf>,
6488 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
6489) -> Result<()> {
6490 let canonical = std::fs::canonicalize(repository)
6491 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6492 if !visited.insert(canonical.clone()) {
6493 bail!("submodule recursion revisited {}", canonical.display());
6494 }
6495 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6496 let head = head.trim().to_string();
6497 if head.is_empty() {
6498 bail!("git returned an empty head for {}", repository.display());
6499 }
6500 let unsafe_index_flags = unsafe_index_flags(repository)?;
6501 let tracked = tracked_entries(repository)?;
6502 let gitlinks = gitlinks(repository)?;
6503 if repositories
6504 .insert(
6505 prefix.to_path_buf(),
6506 RepositoryState {
6507 head,
6508 unsafe_index_flags,
6509 tracked,
6510 gitlinks: gitlinks
6511 .iter()
6512 .map(|link| (link.path.clone(), link.oid.clone()))
6513 .collect(),
6514 },
6515 )
6516 .is_some()
6517 {
6518 bail!("Git state contains duplicate repository path {:?}", prefix);
6519 }
6520
6521 for link in gitlinks {
6522 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6523 continue;
6524 };
6525 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6526 }
6527 Ok(())
6528}
6529
6530fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6531 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6532 if !listed.is_empty() && !listed.ends_with(&[0]) {
6533 bail!(
6534 "git returned an unterminated index-flag listing for {}",
6535 cwd.display()
6536 );
6537 }
6538 let mut unsafe_records = Vec::new();
6539 for record in listed
6540 .split(|byte| *byte == 0)
6541 .filter(|record| !record.is_empty())
6542 {
6543 if record.len() < 3 || record[1] != b' ' {
6544 bail!(
6545 "git returned a malformed index-flag record for {}",
6546 cwd.display()
6547 );
6548 }
6549 if record[0] != b'H' {
6550 unsafe_records.extend_from_slice(record);
6551 unsafe_records.push(0);
6552 }
6553 }
6554 Ok(unsafe_records)
6555}
6556
6557pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6558 safe_git_state(cwd).map(|_| ())
6559}
6560
6561pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6562 let state = git_state(cwd)?;
6563 if let Some((path, _repository)) = state
6564 .repositories
6565 .iter()
6566 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6567 {
6568 let label = if path.as_os_str().is_empty() {
6569 cwd.to_path_buf()
6570 } else {
6571 cwd.join(path)
6572 };
6573 bail!(
6574 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6575 SPAR cannot prove the working files are unchanged, so it was kept.",
6576 label.display()
6577 );
6578 }
6579 Ok(state)
6580}
6581
6582fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6583 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
6584}
6585
6586fn repository_has_recoverable_work_with(
6587 cwd: &Path,
6588 include_ignored: bool,
6589 disposable: Disposable,
6590) -> Result<bool> {
6591 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6592 return Ok(true);
6593 }
6594 let mut visited = BTreeSet::new();
6595 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
6596}
6597
6598fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6599 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6600 let git_dir = PathBuf::from(git_dir.trim());
6601 let git_dir = if git_dir.is_absolute() {
6602 git_dir
6603 } else {
6604 cwd.join(git_dir)
6605 };
6606 let git_dir = std::fs::canonicalize(&git_dir)
6607 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6608 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6609 Ok(_) => return Ok(true),
6610 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6611 Err(error) => {
6612 return Err(spar_err!(
6613 "could not inspect per-worktree configuration in {}: {error}",
6614 git_dir.display()
6615 ))
6616 }
6617 }
6618
6619 let orig_head = git_dir.join("ORIG_HEAD");
6620 match std::fs::symlink_metadata(&orig_head) {
6621 Ok(metadata) if metadata.is_file() => {
6622 let oid = std::fs::read_to_string(&orig_head)
6623 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6624 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6625 return Ok(true);
6626 };
6627 if !commit_has_shared_ref(cwd, &commit)? {
6628 return Ok(true);
6629 }
6630 }
6631 Ok(_) => return Ok(true),
6632 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6633 Err(error) => {
6634 return Err(spar_err!(
6635 "could not inspect {}: {error}",
6636 orig_head.display()
6637 ))
6638 }
6639 }
6640
6641 let edit_message = git_dir.join("COMMIT_EDITMSG");
6642 match std::fs::symlink_metadata(&edit_message) {
6643 Ok(metadata) if metadata.is_file() => {
6644 let draft = std::fs::read(&edit_message)
6645 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6646 if draft != head_commit_message(cwd)? {
6647 return Ok(true);
6648 }
6649 }
6650 Ok(_) => return Ok(true),
6651 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6652 Err(error) => {
6653 return Err(spar_err!(
6654 "could not inspect {}: {error}",
6655 edit_message.display()
6656 ))
6657 }
6658 }
6659
6660 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6661 return Ok(true);
6662 }
6663
6664 let local_refs = run_git_bytes(
6665 cwd,
6666 &[
6667 "for-each-ref",
6668 "--format=%(refname)",
6669 "refs/worktree",
6670 "refs/bisect",
6671 "refs/rewritten",
6672 ],
6673 )?;
6674 if !local_refs.is_empty() {
6675 return Ok(true);
6676 }
6677
6678 for entry in std::fs::read_dir(&git_dir)
6679 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6680 {
6681 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6682 let known = matches!(
6683 entry.file_name().to_str(),
6684 Some(
6685 "HEAD"
6686 | "ORIG_HEAD"
6687 | "COMMIT_EDITMSG"
6688 | "commondir"
6689 | "gitdir"
6690 | "index"
6691 | "logs"
6692 | "refs"
6693 )
6694 );
6695 if !known {
6696 return Ok(true);
6697 }
6698 }
6699
6700 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6701 if !commit_has_shared_ref(cwd, head.trim())? {
6702 return Ok(true);
6703 }
6704 Ok(false)
6705}
6706
6707fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6708 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6709 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6710 bail!(
6711 "git returned a commit without a message separator in {}",
6712 cwd.display()
6713 );
6714 };
6715 Ok(commit[split + 2..].to_vec())
6716}
6717
6718fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6719 let metadata = match std::fs::symlink_metadata(logs) {
6720 Ok(metadata) => metadata,
6721 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6722 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6723 };
6724 if !metadata.is_dir() {
6725 return Ok(true);
6726 }
6727 let mut files = Vec::new();
6728 let mut directories = vec![logs.to_path_buf()];
6729 while let Some(directory) = directories.pop() {
6730 for entry in std::fs::read_dir(&directory)
6731 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6732 {
6733 let entry =
6734 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6735 let path = entry.path();
6736 let kind = entry
6737 .file_type()
6738 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6739 if kind.is_dir() {
6740 directories.push(path);
6741 } else if kind.is_file() {
6742 files.push(path);
6743 } else {
6744 return Ok(true);
6745 }
6746 }
6747 }
6748
6749 let mut commits = BTreeSet::new();
6750 for path in files {
6751 if !collect_reflog_commits(cwd, &path, &mut commits)? {
6752 return Ok(true);
6753 }
6754 }
6755 for commit in commits {
6756 if !commit_has_shared_ref(cwd, &commit)? {
6757 return Ok(true);
6758 }
6759 }
6760 Ok(false)
6761}
6762
6763fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6767 let common = common_git_dir(cwd)?;
6768 let reflog = common.join("logs").join(refname);
6769 let metadata = match std::fs::symlink_metadata(&reflog) {
6770 Ok(metadata) => metadata,
6771 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6772 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6773 };
6774 if !metadata.is_file() {
6775 return Ok(false);
6776 }
6777 let mut commits = BTreeSet::new();
6778 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6779 return Ok(false);
6780 }
6781 for commit in commits {
6782 if is_ancestor(cwd, &commit, durable_tip)?
6783 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6784 {
6785 continue;
6786 }
6787 return Ok(false);
6788 }
6789 Ok(true)
6790}
6791
6792fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6793 let file = std::fs::File::open(path)
6794 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6795 for line in std::io::BufReader::new(file).lines() {
6796 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6797 let mut fields = line.splitn(3, ' ');
6798 let Some(old) = fields.next() else {
6799 return Ok(false);
6800 };
6801 let Some(new) = fields.next() else {
6802 return Ok(false);
6803 };
6804 if fields.next().is_none() {
6805 return Ok(false);
6806 }
6807 for oid in [old, new] {
6808 if oid.bytes().all(|byte| byte == b'0') {
6809 continue;
6810 }
6811 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6812 return Ok(false);
6813 };
6814 commits.insert(commit);
6815 }
6816 }
6817 Ok(true)
6818}
6819
6820fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6821 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6822 let path = PathBuf::from(raw.trim());
6823 let path = if path.is_absolute() {
6824 path
6825 } else {
6826 cwd.join(path)
6827 };
6828 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6829}
6830
6831fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6832 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6833 let output = proc::exec(
6834 &argv,
6835 &ExecOpts::new()
6836 .cwd(cwd)
6837 .timeout_secs(30)
6838 .check(false)
6839 .stop_descendants(true),
6840 )?;
6841 match output.code {
6842 0 => Ok(true),
6843 1 => Ok(false),
6844 _ => bail!("{}", proc::failure_message(&argv, &output)),
6845 }
6846}
6847
6848fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6849 let commit = format!("{oid}^{{commit}}");
6850 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6851 let output = proc::exec(
6852 &argv,
6853 &ExecOpts::new()
6854 .cwd(cwd)
6855 .timeout_secs(30)
6856 .check(false)
6857 .stop_descendants(true),
6858 )?;
6859 if output.code != 0 {
6860 return Ok(None);
6861 }
6862 let oid = output.stdout.trim();
6863 if oid.is_empty() {
6864 return Ok(None);
6865 }
6866 Ok(Some(oid.to_string()))
6867}
6868
6869fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6870 commit_has_shared_ref_except(cwd, oid, None)
6871}
6872
6873fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6874 let contains = format!("--contains={oid}");
6875 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6876 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6877 !record.is_empty()
6878 && !record.starts_with(b"refs/worktree/")
6879 && !record.starts_with(b"refs/bisect/")
6880 && !record.starts_with(b"refs/rewritten/")
6881 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6882 }))
6883}
6884
6885fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
6900 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6901 for raw in &ordinary {
6902 let (path, _) = untracked_record(raw, "untracked")?;
6903 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
6904 continue;
6905 }
6906 return Ok(true);
6907 }
6908 let listed = untracked_listing(
6909 cwd,
6910 &[
6911 "ls-files",
6912 "--others",
6913 "--ignored",
6914 "--exclude-standard",
6915 "-z",
6916 ],
6917 )?;
6918 for raw in listed {
6919 let (path, nested) = untracked_record(&raw, "ignored")?;
6920 if nested {
6924 return Ok(true);
6925 }
6926 if disposable == Disposable::BuildOutput && !is_generated_artifact(&path) {
6927 return Ok(true);
6928 }
6929 }
6930 Ok(false)
6931}
6932
6933fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6934 let listed = run_git_bytes(cwd, args)?;
6935 if !listed.is_empty() && !listed.ends_with(&[0]) {
6936 bail!(
6937 "git returned an unterminated untracked-file list for {}",
6938 cwd.display()
6939 );
6940 }
6941 Ok(listed
6942 .split(|byte| *byte == 0)
6943 .filter(|raw| !raw.is_empty())
6944 .map(|raw| raw.to_vec())
6945 .collect())
6946}
6947
6948fn repository_has_recoverable_work_inner(
6949 cwd: &Path,
6950 include_ignored: bool,
6951 disposable: Disposable,
6952 visited: &mut BTreeSet<PathBuf>,
6953) -> Result<bool> {
6954 let canonical = std::fs::canonicalize(cwd)
6955 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6956 if !visited.insert(canonical.clone()) {
6957 bail!("submodule recursion revisited {}", canonical.display());
6958 }
6959 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
6960 return Ok(true);
6961 }
6962 if !unsafe_index_flags(cwd)?.is_empty() {
6963 return Ok(true);
6964 }
6965 if attributes_may_be_modified(cwd)? {
6966 return Ok(true);
6967 }
6968 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6969 return Ok(true);
6970 }
6971 if include_ignored {
6972 let index = index_entries(cwd)?
6973 .into_iter()
6974 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6975 .collect::<BTreeMap<_, _>>();
6976 let head = tree_entries(cwd, "HEAD")?
6977 .into_iter()
6978 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6979 .collect::<BTreeMap<_, _>>();
6980 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6981 return Ok(true);
6982 }
6983 let tracked = tracked_entries(cwd)?;
6984 let effective = check_attributes(cwd, tracked.keys().cloned())?;
6985 let config = CheckoutConfig::read(cwd)?;
6986 for (path, entry) in tracked {
6987 let Some(worktree) = entry.worktree else {
6988 return Ok(true);
6989 };
6990 let attributes = effective.get(&path).ok_or_else(|| {
6991 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6992 })?;
6993 if path_has_ambiguous_transform(&config, attributes)? {
6994 return Ok(true);
6995 }
6996 let symlink_file = entry.index_mode == "120000"
6997 && worktree.mode == "100644"
6998 && worktree.raw_oid == entry.index_oid
6999 && config.symlinks == Some(false);
7000 if worktree.mode != entry.index_mode && !symlink_file {
7001 return Ok(true);
7002 }
7003 if entry.index_mode == "120000" {
7004 if worktree.raw_oid != entry.index_oid {
7005 return Ok(true);
7006 }
7007 continue;
7008 }
7009 #[cfg(unix)]
7010 {
7011 let expected = if entry.index_mode == "100755" {
7012 0o755
7013 } else {
7014 0o644
7015 };
7016 if worktree.permissions != expected {
7017 return Ok(true);
7018 }
7019 }
7020 if allows_expected_crlf(&config, attributes)? {
7021 let (normalized, every_lf_was_crlf) =
7022 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
7023 if !every_lf_was_crlf || normalized != entry.index_oid {
7024 return Ok(true);
7025 }
7026 } else if worktree.raw_oid != entry.index_oid {
7027 return Ok(true);
7028 }
7029 }
7030 } else {
7031 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
7032 if !run_git_bytes(cwd, &args)?.is_empty() {
7033 return Ok(true);
7034 }
7035 }
7036 for link in gitlinks(cwd)? {
7037 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
7038 continue;
7039 };
7040 if include_ignored {
7045 return Ok(true);
7046 }
7047 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7048 if head.trim() != link.oid {
7049 return Ok(true);
7050 }
7051 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
7052 {
7053 return Ok(true);
7054 }
7055 }
7056 Ok(false)
7057}
7058
7059pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
7060 repository_has_recoverable_work(cwd, false)
7061}
7062
7063fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
7064 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
7065 Ok(!run_git_bytes(cwd, &args)?.is_empty())
7066}
7067
7068#[cfg(unix)]
7069fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7070 use std::os::unix::ffi::OsStringExt;
7071 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
7072}
7073
7074#[cfg(not(unix))]
7075fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7076 String::from_utf8(raw.to_vec())
7077 .map(PathBuf::from)
7078 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
7079}
7080
7081fn vanished_untracked_file() -> UntrackedFile {
7097 UntrackedFile {
7098 kind: 0,
7099 len: 0,
7100 modified: None,
7101 created: None,
7102 readonly: false,
7103 symlink_target: None,
7104 #[cfg(unix)]
7105 device: 0,
7106 #[cfg(unix)]
7107 inode: 0,
7108 #[cfg(unix)]
7109 mode: 0,
7110 #[cfg(unix)]
7111 change_seconds: 0,
7112 #[cfg(unix)]
7113 change_nanoseconds: 0,
7114 }
7115}
7116
7117fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
7118 let metadata = match std::fs::symlink_metadata(path) {
7119 Ok(metadata) => metadata,
7120 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7121 Err(e) => {
7122 return Err(spar_err!(
7123 "could not inspect the nested repository at {}: {e}",
7124 path.display()
7125 ))
7126 }
7127 };
7128 if !metadata.is_dir() {
7129 bail!(
7130 "git reported {} as a nested repository, but it is not a directory",
7131 path.display()
7132 );
7133 }
7134 if std::fs::symlink_metadata(path.join(".git")).is_err() {
7135 bail!(
7136 "git reported {} as a nested repository, but it has no Git entry",
7137 path.display()
7138 );
7139 }
7140 #[cfg(unix)]
7141 {
7142 use std::os::unix::fs::MetadataExt;
7143 Ok(UntrackedFile {
7144 kind: 3,
7145 len: 0,
7146 modified: None,
7147 created: metadata.created().ok(),
7148 readonly: metadata.permissions().readonly(),
7149 symlink_target: None,
7150 device: metadata.dev(),
7151 inode: metadata.ino(),
7152 mode: metadata.mode(),
7153 change_seconds: 0,
7154 change_nanoseconds: 0,
7155 })
7156 }
7157 #[cfg(not(unix))]
7158 {
7159 Ok(UntrackedFile {
7160 kind: 3,
7161 len: 0,
7162 modified: None,
7163 created: metadata.created().ok(),
7164 readonly: metadata.permissions().readonly(),
7165 symlink_target: None,
7166 })
7167 }
7168}
7169
7170fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7171 let metadata = match std::fs::symlink_metadata(path) {
7172 Ok(metadata) => metadata,
7173 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7174 Err(e) => {
7175 return Err(spar_err!(
7176 "could not inspect untracked file {}: {e}",
7177 path.display()
7178 ))
7179 }
7180 };
7181 let kind = if metadata.file_type().is_symlink() {
7182 2
7183 } else if metadata.is_file() {
7184 1
7185 } else {
7186 bail!(
7187 "untracked path {} is not a regular file or symlink",
7188 path.display()
7189 );
7190 };
7191 let symlink_target = if kind == 2 {
7192 match std::fs::read_link(path) {
7193 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7194 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7195 return Ok(vanished_untracked_file())
7196 }
7197 Err(e) => {
7198 return Err(spar_err!(
7199 "could not read untracked symlink {}: {e}",
7200 path.display()
7201 ))
7202 }
7203 }
7204 } else {
7205 None
7206 };
7207 #[cfg(unix)]
7208 {
7209 use std::os::unix::fs::MetadataExt;
7210 Ok(UntrackedFile {
7211 kind,
7212 len: metadata.len(),
7213 modified: metadata.modified().ok(),
7214 created: metadata.created().ok(),
7215 readonly: metadata.permissions().readonly(),
7216 symlink_target,
7217 device: metadata.dev(),
7218 inode: metadata.ino(),
7219 mode: metadata.mode(),
7220 change_seconds: metadata.ctime(),
7221 change_nanoseconds: metadata.ctime_nsec(),
7222 })
7223 }
7224 #[cfg(not(unix))]
7225 {
7226 Ok(UntrackedFile {
7227 kind,
7228 len: metadata.len(),
7229 modified: metadata.modified().ok(),
7230 created: metadata.created().ok(),
7231 readonly: metadata.permissions().readonly(),
7232 symlink_target,
7233 })
7234 }
7235}
7236
7237#[cfg(unix)]
7238fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7239 use std::os::unix::ffi::OsStrExt;
7240 Ok(value.as_bytes().to_vec())
7241}
7242
7243#[cfg(not(unix))]
7244fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7245 value
7246 .to_str()
7247 .map(|value| value.as_bytes().to_vec())
7248 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7249}
7250
7251#[cfg(unix)]
7252fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7253 use std::os::unix::fs::MetadataExt;
7254 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7255}
7256
7257#[cfg(not(unix))]
7258fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7259 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7260}
7261
7262#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7263pub struct BranchRecord {
7264 pub kind: String,
7265 pub number: i64,
7266}
7267
7268pub fn review_ref(number: i64) -> String {
7271 format!("refs/spar/pr-{number}")
7272}
7273
7274pub fn is_finished(state: &str) -> bool {
7275 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7276}
7277
7278pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7285 if let Some(parent) = path.parent() {
7286 std::fs::create_dir_all(parent)
7287 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7288 }
7289 let tmp = path.with_extension(format!(
7292 "{}.tmp",
7293 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7294 ));
7295 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7296 std::fs::rename(&tmp, path)
7297 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7298 Ok(())
7299}
7300
7301pub fn has_heading(text: &str, title: &str) -> bool {
7308 let wanted = title.trim().to_lowercase();
7309 if wanted.is_empty() {
7310 return false;
7311 }
7312 text.lines()
7313 .filter_map(|line| line.trim().strip_prefix("## "))
7314 .any(|heading| heading.trim().to_lowercase() == wanted)
7315}
7316
7317pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7320 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7321}
7322
7323#[derive(Deserialize)]
7325#[serde(rename_all = "camelCase")]
7326pub struct LinkedPrRow {
7327 number: i64,
7328 #[serde(default)]
7329 url: String,
7330 #[serde(default)]
7331 title: String,
7332 #[serde(default)]
7333 closing_issues_references: Vec<IssueRef>,
7334}
7335
7336pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7343 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7344}
7345
7346pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7348 rows.iter()
7349 .find(|row| {
7350 row.closing_issues_references
7351 .iter()
7352 .any(|linked| linked.number == issue)
7353 })
7354 .map(|row| PrRef {
7355 number: row.number,
7356 url: row.url.clone(),
7357 title: row.title.clone(),
7358 })
7359}
7360
7361pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7363 pick_linked_pr(&linked_pr_rows(json)?, issue)
7364}
7365
7366fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7373 if text.trim().is_empty() {
7374 return Err(spar_err!("GitHub returned no comment data"));
7375 }
7376 let mut out = Vec::new();
7377 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7378 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7379 Value::Array(items) => out.extend(items),
7380 _ => return Err(spar_err!("unexpected non-array comment page")),
7381 }
7382 }
7383 Ok(out)
7384}
7385
7386pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7387 let mut out = Vec::new();
7388 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7389 match value {
7390 Ok(Value::Array(items)) => out.extend(items),
7391 Ok(other) => out.push(other),
7392 Err(_) => break,
7393 }
7394 }
7395 out
7396}
7397
7398pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7406 body.trim_start().starts_with(STATE_MARKER)
7407 && !viewer.trim().is_empty()
7408 && author.trim().eq_ignore_ascii_case(viewer.trim())
7409 && parse_state_comment(body).is_some()
7410}
7411
7412pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7415 let marker = body.find(STATE_MARKER)?;
7416 let start = body[marker..].find('{')? + marker;
7417 let end = body.rfind('}')?;
7418 if end <= start {
7419 return None;
7420 }
7421 match serde_json::from_str(&body[start..=end]) {
7422 Ok(state) => Some(state),
7423 Err(_) => {
7424 logdim!("found a spar state comment but could not parse it");
7425 None
7426 }
7427 }
7428}
7429
7430fn choose_state_for_head(
7431 candidates: Vec<PersistedState>,
7432 actual_head: &str,
7433) -> Option<PersistedState> {
7434 let matching: Vec<PersistedState> = candidates
7435 .iter()
7436 .filter(|state| state.pr_head == actual_head)
7437 .cloned()
7438 .collect();
7439 if !matching.is_empty() {
7440 return newest_state(matching);
7441 }
7442 newest_state(candidates)
7443}
7444
7445fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7446 candidates.into_iter().reduce(|best, candidate| {
7447 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7448 candidate
7449 } else {
7450 best
7455 }
7456 })
7457}
7458
7459pub fn self_binary() -> Result<PathBuf> {
7465 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7466 let path = PathBuf::from(path);
7467 if proc::is_executable(&path) {
7468 return Ok(path);
7469 }
7470 bail!(
7471 "SPAR_SELF_BIN is set to {}, which is not executable",
7472 path.display()
7473 );
7474 }
7475 std::env::current_exe()
7476 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
7477}
7478
7479fn bool_env(value: bool) -> &'static str {
7480 if value {
7481 "1"
7482 } else {
7483 "0"
7484 }
7485}
7486
7487pub fn sh_quote(text: &str) -> String {
7490 format!("'{}'", text.replace('\'', r"'\''"))
7491}
7492
7493pub fn style_from_env() -> Style {
7496 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
7497 Style {
7498 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
7499 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
7500 ..Style::permissive()
7501 }
7502}
7503
7504#[cfg(test)]
7505mod tests {
7506 use super::*;
7507 use crate::config::StateStore;
7508 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
7509 use std::process::Command;
7510
7511 fn repo_for_titles() -> Repo {
7512 Repo {
7513 root: PathBuf::from("/nonexistent"),
7514 style: Style::default(),
7515 branch_prefix: String::new(),
7516 state_store: StateStore::Local,
7517 followups: crate::config::Followups::Issues,
7518 drafts: Drafts::Never,
7519 viewer: OnceLock::new(),
7520 checkpoints: Mutex::new(BTreeMap::new()),
7521 writes: WriteStats::default(),
7522 }
7523 }
7524
7525 #[test]
7526 fn write_results_accumulate_for_the_run() {
7527 let repo = repo_for_titles();
7528
7529 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
7530 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
7531
7532 assert_eq!(
7533 WriteSummary {
7534 attempted: 2,
7535 failed: 1,
7536 },
7537 repo.write_summary()
7538 );
7539 }
7540
7541 #[test]
7542 fn only_failed_write_preflights_join_the_summary() {
7543 let repo = repo_for_titles();
7544
7545 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
7546 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
7547
7548 assert_eq!(
7549 WriteSummary {
7550 attempted: 1,
7551 failed: 1,
7552 },
7553 repo.write_summary()
7554 );
7555 }
7556
7557 #[test]
7558 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
7559 let repo = repo_for_titles();
7560
7561 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
7562 assert_eq!(
7563 WriteSummary {
7564 attempted: 1,
7565 failed: 1,
7566 },
7567 repo.write_summary()
7568 );
7569 }
7570
7571 #[test]
7572 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7573 let mut repo = repo_for_titles();
7574 repo.followups = Followups::Local;
7575
7576 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7577 assert_eq!(WriteSummary::default(), repo.write_summary());
7578 }
7579
7580 #[test]
7581 fn a_failed_remote_state_read_stops_before_state_mutation() {
7582 let root = std::env::temp_dir().join(format!(
7583 "spar-state-preflight-{}-{}",
7584 std::process::id(),
7585 std::time::SystemTime::now()
7586 .duration_since(std::time::UNIX_EPOCH)
7587 .unwrap()
7588 .as_nanos()
7589 ));
7590 std::fs::create_dir_all(&root).unwrap();
7591 let _fixture = ReviewFixture { root: root.clone() };
7592 let mut repo = repo_for_titles();
7593 repo.root = root;
7594 repo.state_store = StateStore::Both;
7595 let state = PersistedState {
7596 version: 1,
7597 checkpoint: 4,
7598 round: 2,
7599 next_actor: "a".into(),
7600 status: Status::Pending,
7601 pr_head: "abc123".into(),
7602 ledger: Ledger::new(),
7603 filed: Vec::new(),
7604 open_findings: Vec::new(),
7605 disputes: Vec::new(),
7606 noted: Vec::new(),
7607 };
7608
7609 let error = repo
7610 .write_state_after_remote_read(
7611 7,
7612 &state,
7613 Err(crate::error::SparError::new("state comments unavailable")),
7614 )
7615 .unwrap_err();
7616
7617 assert!(error.to_string().contains("state comments unavailable"));
7618 assert!(!repo.state_path(7).exists());
7619 assert_eq!(0, repo.remembered_checkpoint(7));
7620 assert_eq!(
7621 WriteSummary {
7622 attempted: 1,
7623 failed: 1,
7624 },
7625 repo.write_summary()
7626 );
7627 }
7628
7629 #[test]
7630 fn only_known_build_and_cache_directories_are_generated_artifacts() {
7631 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7632 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7633 assert!(is_generated_artifact(Path::new(
7634 "package/node_modules/dependency/file.js"
7635 )));
7636 assert!(!is_generated_artifact(Path::new(
7637 "distribution/required-package.js"
7638 )));
7639 assert!(!is_generated_artifact(Path::new(
7640 "generated/required-fixture.txt"
7641 )));
7642 assert!(!is_generated_artifact(Path::new("local.env")));
7643 }
7644
7645 struct ReviewFixture {
7646 root: PathBuf,
7647 }
7648
7649 impl Drop for ReviewFixture {
7650 fn drop(&mut self) {
7651 let _ = std::fs::remove_dir_all(&self.root);
7652 }
7653 }
7654
7655 fn test_git(cwd: &Path, args: &[&str]) -> String {
7656 let output = Command::new("git")
7657 .args(args)
7658 .current_dir(cwd)
7659 .output()
7660 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7661 assert!(
7662 output.status.success(),
7663 "git {args:?} failed: {}",
7664 String::from_utf8_lossy(&output.stderr)
7665 );
7666 String::from_utf8_lossy(&output.stdout).into_owned()
7667 }
7668
7669 fn review_fixture(
7670 tag: &str,
7671 number: i64,
7672 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7673 use std::sync::atomic::{AtomicU32, Ordering};
7674 static NEXT: AtomicU32 = AtomicU32::new(0);
7675 let id = NEXT.fetch_add(1, Ordering::Relaxed);
7676 let root =
7677 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7678 let origin = root.join("origin.git");
7679 let work = root.join("work");
7680 std::fs::create_dir_all(&origin).unwrap();
7681 std::fs::create_dir_all(&work).unwrap();
7682 test_git(&origin, &["init", "--bare", "-b", "main"]);
7683 test_git(&work, &["init", "-b", "main"]);
7684 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7685 test_git(&work, &["config", "user.name", "spar test"]);
7686 test_git(&work, &["config", "commit.gpgsign", "false"]);
7687 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7688 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7689 std::fs::write(work.join("README.md"), "seed\n").unwrap();
7690 std::fs::write(work.join("data.txt"), "old\n").unwrap();
7691 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7692 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7693 test_git(&work, &["add", "."]);
7694 test_git(&work, &["commit", "-m", "seed"]);
7695 test_git(
7696 &work,
7697 &["remote", "add", "origin", origin.to_str().unwrap()],
7698 );
7699 test_git(&work, &["push", "-u", "origin", "main"]);
7700 test_git(
7701 &work,
7702 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7703 );
7704 let cfg = crate::config::parse(
7705 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7706 )
7707 .unwrap();
7708 let repo = Repo::open(&work, &cfg).unwrap();
7709 let path = repo.worktree_for_pr_head(number).unwrap();
7710 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7711 (ReviewFixture { root }, repo, path, checkpoint)
7712 }
7713
7714 #[test]
7715 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7716 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7717
7718 repo.release_review_worktree_checked(901, &checkpoint)
7719 .unwrap();
7720
7721 assert!(!path.exists());
7722 }
7723
7724 #[test]
7725 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7726 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7727 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7728 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7729 test_git(&path, &["add", "recovery.txt"]);
7730 test_git(&path, &["commit", "-m", "recovery commit"]);
7731 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7732 test_git(&path, &["reset", "--hard", "main"]);
7733
7734 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7735 test_git(
7736 &path,
7737 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7738 );
7739 }
7740
7741 #[test]
7742 fn a_review_ref_reflog_only_commit_prevents_deletion() {
7743 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7744 let local_ref = review_ref(921);
7745 let original = test_git(&path, &["rev-parse", &local_ref]);
7746 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7747 let recovery = test_git(
7748 &path,
7749 &[
7750 "commit-tree",
7751 tree.trim(),
7752 "-p",
7753 original.trim(),
7754 "-m",
7755 "review ref recovery",
7756 ],
7757 );
7758 test_git(
7759 &path,
7760 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7761 );
7762 test_git(
7763 &path,
7764 &["update-ref", &local_ref, original.trim(), recovery.trim()],
7765 );
7766
7767 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7768 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7769 test_git(
7770 &path,
7771 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7772 );
7773 }
7774
7775 #[test]
7776 fn an_unpublished_commit_message_draft_is_recoverable() {
7777 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7778 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7779 let git_dir = if raw.is_absolute() {
7780 raw
7781 } else {
7782 path.join(raw)
7783 };
7784 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7785
7786 assert!(repository_has_recoverable_work(&path, true).unwrap());
7787 assert_eq!(
7788 "unique recovery draft\n",
7789 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7790 );
7791 }
7792
7793 #[test]
7794 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7795 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7796 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7797
7798 let error = repo
7799 .release_review_worktree_checked(902, &checkpoint)
7800 .unwrap_err();
7801
7802 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7803 assert!(error.to_string().contains("kept for recovery"), "{error}");
7804 assert_eq!(
7805 "recover me\n",
7806 std::fs::read_to_string(path.join("README.md")).unwrap()
7807 );
7808 repo.release_review_worktree(902);
7809 }
7810
7811 #[test]
7812 fn a_review_commit_is_retained_after_a_checked_read() {
7813 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7814 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7815 test_git(&path, &["add", "review-note.txt"]);
7816 test_git(&path, &["commit", "-m", "local review recovery"]);
7817 let head = test_git(&path, &["rev-parse", "HEAD"]);
7818
7819 let error = repo
7820 .release_review_worktree_checked(903, &checkpoint)
7821 .unwrap_err();
7822
7823 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7824 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7825 assert_eq!(
7826 "recover me\n",
7827 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7828 );
7829 repo.release_review_worktree(903);
7830 }
7831
7832 #[test]
7836 fn an_ignored_review_file_is_retained_after_a_checked_read() {
7837 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7838 std::fs::create_dir_all(path.join("generated")).unwrap();
7839 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7840
7841 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7842 .expect("its own output is not a change");
7843 let error = repo
7844 .release_review_worktree_checked(904, &checkpoint)
7845 .unwrap_err();
7846
7847 assert!(error.to_string().contains("recoverable"), "{error}");
7848 assert_eq!(
7849 "recover me\n",
7850 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7851 );
7852 repo.release_review_worktree(904);
7853 }
7854
7855 #[test]
7856 fn a_preexisting_ignored_review_file_change_is_retained() {
7862 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7863 std::fs::create_dir_all(path.join("generated")).unwrap();
7864 let ignored = path.join("generated/recovery.txt");
7865 std::fs::write(&ignored, "before\n").unwrap();
7866 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7867 std::fs::write(&ignored, "after!\n").unwrap();
7868
7869 let error = repo
7870 .release_review_worktree_checked(905, &checkpoint)
7871 .unwrap_err();
7872
7873 assert!(error.to_string().contains("recoverable"), "{error}");
7874 assert!(path.exists());
7875 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7876 repo.release_review_worktree(905);
7877 }
7878
7879 #[test]
7880 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7881 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7882 std::fs::create_dir_all(path.join("generated")).unwrap();
7883 let ignored = path.join("generated/recovery.txt");
7884 std::fs::write(&ignored, "keep me\n").unwrap();
7885 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7886
7887 let error = repo
7888 .release_review_worktree_checked(906, &checkpoint)
7889 .unwrap_err();
7890
7891 assert!(error.to_string().contains("recoverable"), "{error}");
7892 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7893 }
7894
7895 #[test]
7896 fn overwriting_a_preexisting_untracked_file_is_detected() {
7897 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7898 let untracked = path.join("notes.txt");
7899 std::fs::write(&untracked, "before\n").unwrap();
7900 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7901 std::fs::write(&untracked, "after!\n").unwrap();
7902
7903 let error = repo
7904 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7905 .unwrap_err();
7906
7907 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7908 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7909 }
7910
7911 #[test]
7912 fn an_assume_unchanged_edit_is_detected() {
7913 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7914 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7915 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7916
7917 let error = repo
7918 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7919 .unwrap_err();
7920
7921 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7922 assert_eq!(
7923 "hidden\n",
7924 std::fs::read_to_string(path.join("README.md")).unwrap()
7925 );
7926 }
7927
7928 #[test]
7929 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7930 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7931 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7932 test_git(&path, &["add", "README.md"]);
7933 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7934
7935 let error = repo
7936 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7937 .unwrap_err();
7938
7939 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7940 assert_eq!(
7941 b"seed\r\n",
7942 std::fs::read(path.join("README.md")).unwrap().as_slice()
7943 );
7944 }
7945
7946 #[cfg(unix)]
7947 #[test]
7948 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7949 use std::os::unix::fs::PermissionsExt;
7950
7951 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7952 test_git(&path, &["config", "core.filemode", "false"]);
7953 let readme = path.join("README.md");
7954 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7955 permissions.set_mode(0o755);
7956 std::fs::set_permissions(&readme, permissions).unwrap();
7957 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7958
7959 let error = repo
7960 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7961 .unwrap_err();
7962
7963 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7964 assert_eq!(
7965 0o755,
7966 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7967 );
7968 }
7969
7970 #[test]
7971 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7972 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7973 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7974 test_git(&path, &["add", ".gitattributes"]);
7975 test_git(&path, &["commit", "-m", "select data filter"]);
7976 let baseline = repo.worktree_baseline(&path).unwrap();
7977 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7978
7979 assert!(repo
7980 .commit_pending_changes(&path, &baseline, "change data", "change data")
7981 .unwrap());
7982 let error = repo
7983 .refuse_unrepresented_tracked_changes(&path, &baseline)
7984 .unwrap_err();
7985
7986 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7987 assert_eq!(
7988 "secret: recover me\nnew\n",
7989 std::fs::read_to_string(path.join("data.txt")).unwrap()
7990 );
7991 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7992 }
7993
7994 #[test]
7995 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7996 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7997 std::fs::create_dir_all(path.join("target")).unwrap();
7998 let untracked = path.join("target/user.yaml");
7999 std::fs::write(&untracked, "user data\n").unwrap();
8000 let baseline = repo.worktree_baseline(&path).unwrap();
8001 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8002
8003 assert!(repo
8004 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8005 .unwrap());
8006
8007 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
8008 assert_eq!(
8009 "?? target/user.yaml\n",
8010 test_git(&path, &["status", "--short", "--untracked-files=all"])
8011 );
8012 assert!(test_git(
8013 &path,
8014 &[
8015 "ls-tree",
8016 "-r",
8017 "--name-only",
8018 "HEAD",
8019 "--",
8020 "target/user.yaml"
8021 ]
8022 )
8023 .is_empty());
8024 }
8025
8026 #[test]
8027 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
8028 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
8029 std::fs::create_dir_all(path.join("target")).unwrap();
8030 let untracked = path.join("target/user.yaml");
8031 std::fs::write(&untracked, "before\n").unwrap();
8032 let baseline = repo.worktree_baseline(&path).unwrap();
8033 let before = test_git(&path, &["rev-parse", "HEAD"]);
8034 std::fs::write(&untracked, "after\n").unwrap();
8035 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8036
8037 let error = repo
8038 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8039 .unwrap_err();
8040
8041 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8042 assert!(error.to_string().contains("target/user.yaml"), "{error}");
8043 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8044 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
8045 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
8046 }
8047
8048 #[test]
8049 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
8050 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
8051 let baseline = repo.worktree_baseline(&path).unwrap();
8052 std::fs::create_dir_all(path.join("target")).unwrap();
8053 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
8054
8055 assert!(repo
8056 .commit_pending_changes(&path, &baseline, "add file", "add file")
8057 .unwrap());
8058
8059 assert_eq!(
8060 "new file\n",
8061 test_git(&path, &["show", "HEAD:target/new.txt"])
8062 );
8063 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8064 }
8065
8066 #[test]
8067 fn deleting_existing_ignored_work_stops_a_managed_commit() {
8068 let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
8069 std::fs::create_dir_all(path.join("generated")).unwrap();
8070 let ignored = path.join("generated/keep.txt");
8071 std::fs::write(&ignored, "user data\n").unwrap();
8072 let baseline = repo.worktree_baseline(&path).unwrap();
8073 let before = test_git(&path, &["rev-parse", "HEAD"]);
8074 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8075 std::fs::remove_file(&ignored).unwrap();
8076
8077 let error = repo
8078 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8079 .unwrap_err();
8080
8081 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8082 assert!(error.to_string().contains("existing untracked"), "{error}");
8083 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8084 assert_eq!(
8085 "tracked change\n",
8086 std::fs::read_to_string(path.join("README.md")).unwrap()
8087 );
8088 }
8089
8090 #[test]
8091 fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
8092 let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
8093 let baseline = repo.worktree_baseline(&path).unwrap();
8094 let before = test_git(&path, &["rev-parse", "HEAD"]);
8095 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8096 std::fs::create_dir_all(path.join("generated")).unwrap();
8097 let ignored = path.join("generated/recovery.txt");
8098 std::fs::write(&ignored, "keep me\n").unwrap();
8099
8100 let error = repo
8101 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8102 .unwrap_err();
8103
8104 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8105 assert!(error.to_string().contains("recovery.txt"), "{error}");
8106 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8107 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
8108 assert!(test_git(&path, &["status", "--porcelain"])
8109 .lines()
8110 .any(|line| line == "M README.md"));
8111 }
8112
8113 #[test]
8114 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
8115 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
8116 test_git(&path, &["config", "core.autocrlf", "true"]);
8117 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8118 assert_eq!(
8119 test_git(&path, &["hash-object", "README.md"]).trim(),
8120 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8121 );
8122
8123 assert!(repository_has_recoverable_work(&path, true).unwrap());
8124 assert_eq!(
8125 "seed\n",
8126 std::fs::read_to_string(path.join("README.md")).unwrap()
8127 );
8128 }
8129
8130 #[test]
8131 fn autocrlf_input_overrides_a_crlf_core_eol() {
8132 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8133 test_git(&path, &["config", "core.autocrlf", "input"]);
8134 test_git(&path, &["config", "core.eol", "crlf"]);
8135 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8136 assert_eq!(
8137 test_git(&path, &["hash-object", "README.md"]).trim(),
8138 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8139 );
8140
8141 assert!(repository_has_recoverable_work(&path, true).unwrap());
8142 assert_eq!(
8143 b"seed\r\n",
8144 std::fs::read(path.join("README.md")).unwrap().as_slice()
8145 );
8146 }
8147
8148 #[cfg(unix)]
8149 #[test]
8150 fn a_non_executable_permission_change_is_recoverable() {
8151 use std::os::unix::fs::PermissionsExt;
8152
8153 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8154 let readme = path.join("README.md");
8155 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8156 permissions.set_mode(0o600);
8157 std::fs::set_permissions(&readme, permissions).unwrap();
8158 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8159
8160 let error = repo
8161 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8162 .unwrap_err();
8163
8164 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8165 assert!(repository_has_recoverable_work(&path, true).unwrap());
8166 assert_eq!(
8167 0o600,
8168 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8169 );
8170 }
8171
8172 #[cfg(unix)]
8173 #[test]
8174 fn a_managed_commit_skips_signing_and_hooks() {
8175 use std::os::unix::fs::PermissionsExt;
8176
8177 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8178 let common = common_git_dir(&path).unwrap();
8179 let hook = common.join("hooks/pre-commit");
8180 let marker = fixture.root.join("hook-ran");
8181 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8182 std::fs::write(
8183 &hook,
8184 format!(
8185 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8186 sh_quote(marker.to_str().unwrap())
8187 ),
8188 )
8189 .unwrap();
8190 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8191 permissions.set_mode(0o755);
8192 std::fs::set_permissions(&hook, permissions).unwrap();
8193 test_git(&path, &["config", "commit.gpgsign", "true"]);
8194 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8195 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8196 test_git(&path, &["add", "managed.txt"]);
8197
8198 repo.commit_staged_changes(&path, "record managed change")
8199 .unwrap();
8200
8201 assert!(!marker.exists());
8202 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8203 }
8204
8205 #[test]
8206 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8207 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8208 std::fs::write(
8209 path.join(".gitattributes"),
8210 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8211 )
8212 .unwrap();
8213 test_git(&path, &["add", ".gitattributes"]);
8214 test_git(&path, &["commit", "-m", "select automatic text"]);
8215 test_git(&path, &["config", "core.autocrlf", "true"]);
8216
8217 assert!(repository_has_recoverable_work(&path, true).unwrap());
8218 }
8219
8220 #[test]
8221 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8222 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8223 std::fs::write(
8224 path.join(".gitattributes"),
8225 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8226 )
8227 .unwrap();
8228 test_git(&path, &["add", ".gitattributes"]);
8229 test_git(&path, &["commit", "-m", "select ident expansion"]);
8230 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8231
8232 assert!(repository_has_recoverable_work(&path, true).unwrap());
8233 }
8234
8235 #[test]
8241 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8242 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8243 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8244 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8245 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8246 std::fs::write(
8247 path.join("bindings/uniffi-bindgen/Cargo.lock"),
8248 "# generated\n",
8249 )
8250 .unwrap();
8251
8252 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8253 .expect("a path the project ignores is the call's own output");
8254 }
8255
8256 #[test]
8257 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8258 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8259 exclude_paths(&repo, &["target/"]);
8260 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8261 std::fs::create_dir_all(path.join("target/package")).unwrap();
8262 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8263
8264 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8265 .expect("an ignored attribute file governs only ignored paths");
8266 }
8267
8268 #[test]
8272 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
8273 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
8274 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8275 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
8276
8277 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8278 .expect("spar's own marker is not something a call did");
8279 }
8280
8281 #[test]
8282 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
8283 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
8284
8285 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
8286 .expect("a path a build removed is not an unreadable worktree");
8287
8288 assert_eq!(vanished_untracked_file(), gone);
8289 }
8290
8291 #[test]
8294 fn a_vanished_path_is_not_recorded_as_absent() {
8295 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
8296 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
8297 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8298 std::fs::remove_file(path.join("notes.txt")).unwrap();
8299
8300 let error = repo
8301 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8302 .unwrap_err();
8303
8304 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8305 }
8306
8307 fn exclude_paths(repo: &Repo, lines: &[&str]) {
8308 use std::io::Write;
8309 let path = repo.root().join(".git").join("info").join("exclude");
8310 let mut file = std::fs::OpenOptions::new()
8311 .create(true)
8312 .append(true)
8313 .open(&path)
8314 .unwrap();
8315 for line in lines {
8316 writeln!(file, "{line}").unwrap();
8317 }
8318 }
8319
8320 #[test]
8321 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
8322 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
8323 exclude_paths(&repo, &["dist/"]);
8324 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8325 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8326 let baseline = repo.worktree_baseline(&path).unwrap();
8327 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8328 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8329 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8330 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8331
8332 assert!(repo
8333 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8334 .unwrap());
8335 assert_eq!(
8336 "tracked change\n",
8337 std::fs::read_to_string(path.join("README.md")).unwrap()
8338 );
8339 }
8340
8341 #[test]
8342 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
8343 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
8344 exclude_paths(&repo, &["dist/"]);
8345 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8346 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8347 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
8348 let baseline = repo.worktree_baseline(&path).unwrap();
8349 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8350 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
8351 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8352 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8353
8354 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8355 repo.refuse_changed_existing_untracked(&path, &baseline)
8356 .unwrap();
8357 }
8358
8359 #[test]
8360 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
8361 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
8362 exclude_paths(&repo, &["dist/"]);
8363 std::fs::create_dir_all(path.join("dist")).unwrap();
8364 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8365 let baseline = repo.worktree_baseline(&path).unwrap();
8366 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8367 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8368
8369 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8370 repo.refuse_changed_existing_untracked(&path, &baseline)
8371 .unwrap();
8372 }
8373
8374 #[test]
8383 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
8384 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
8385 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
8386 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
8387 test_git(
8388 repo.root(),
8389 &[
8390 "worktree",
8391 "add",
8392 "-b",
8393 "issue-950",
8394 held.to_str().unwrap(),
8395 "main",
8396 ],
8397 );
8398
8399 let found = repo
8400 .worktree_holding("issue-950")
8401 .expect("the branch is checked out somewhere");
8402
8403 assert!(found.ends_with("issue-950"), "{}", found.display());
8404 assert_eq!(None, repo.worktree_holding("issue-951"));
8405 }
8406
8407 #[test]
8408 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
8409 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
8410 exclude_paths(&repo, &["manager/public/assets/"]);
8411 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
8412 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
8413 std::fs::write(&bundle, "first build\n").unwrap();
8414 let baseline = repo.worktree_baseline(&path).unwrap();
8415 std::fs::write(&bundle, "second build\n").unwrap();
8416 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
8417
8418 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
8419 assert!(repo.refuse_new_ignored_files(&path, &baseline).is_err());
8422 }
8423
8424 #[test]
8425 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
8426 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
8427 exclude_paths(&repo, &["dist/"]);
8428 let baseline = repo.worktree_baseline(&path).unwrap();
8429 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
8430
8431 let error = repo
8432 .refuse_read_only_leavings(&path, &baseline)
8433 .unwrap_err();
8434
8435 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8436 assert!(error.to_string().contains("scratch.md"), "{error}");
8437 }
8438
8439 #[test]
8440 fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
8441 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
8442 exclude_paths(&repo, &["dist/", ".env.local"]);
8443 std::fs::create_dir_all(path.join("dist")).unwrap();
8444 let baseline = repo.worktree_baseline(&path).unwrap();
8445 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
8446 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
8447
8448 let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
8449
8450 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8451 assert!(error.to_string().contains(".env.local"), "{error}");
8452 }
8453
8454 #[test]
8455 fn a_read_only_inspection_may_rebuild_generated_output() {
8456 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
8457 exclude_paths(&repo, &["dist/"]);
8458 std::fs::create_dir_all(path.join("dist")).unwrap();
8459 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8460 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8461 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8462 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8463
8464 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8465 .unwrap();
8466 }
8467
8468 #[test]
8469 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
8470 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
8471 exclude_paths(&repo, &["dist/", ".env.local"]);
8472 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8473 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8474 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
8475
8476 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8477 .unwrap();
8478 }
8479
8480 #[test]
8481 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
8482 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
8483 exclude_paths(&repo, &["dist/", ".env.local"]);
8484 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8485 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8486 std::fs::remove_file(path.join(".env.local")).unwrap();
8487
8488 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8489 .unwrap();
8490 }
8491
8492 #[test]
8493 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
8494 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
8495 exclude_paths(&repo, &["dist/"]);
8496 std::fs::write(path.join("notes.md"), "before\n").unwrap();
8497 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8498 std::fs::write(path.join("notes.md"), "after\n").unwrap();
8499
8500 let error = repo
8501 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8502 .unwrap_err();
8503
8504 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8505 assert!(error.to_string().contains("notes.md"), "{error}");
8506 }
8507
8508 #[test]
8509 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
8510 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
8511 exclude_paths(&repo, &["dist/", "local.env"]);
8512 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
8513 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8514 std::fs::write(
8517 repo.root().join(".git").join("info").join("exclude"),
8518 "dist/\n",
8519 )
8520 .unwrap();
8521
8522 let error = repo
8523 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8524 .unwrap_err();
8525
8526 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8527 assert!(error.to_string().contains("local.env"), "{error}");
8528 }
8529
8530 #[test]
8531 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
8532 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
8533 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8534 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8535
8536 let error = repo
8537 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8538 .unwrap_err();
8539
8540 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8541 assert!(error.to_string().contains("README.md"), "{error}");
8542 }
8543
8544 #[test]
8545 fn build_output_alone_does_not_keep_a_worktree() {
8546 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
8547 exclude_paths(&repo, &["target/", "dist/"]);
8548 std::fs::create_dir_all(path.join("target/debug")).unwrap();
8549 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8550 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
8551 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
8552
8553 assert!(!repository_has_recoverable_work(&path, true).unwrap());
8554 repo.release_review_worktree(933);
8555
8556 assert!(!path.exists());
8557 }
8558
8559 #[test]
8560 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
8561 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
8562 exclude_paths(&repo, &["target/", ".env.local"]);
8563 std::fs::create_dir_all(path.join("target/debug")).unwrap();
8564 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8565 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
8566
8567 assert!(repository_has_recoverable_work(&path, true).unwrap());
8568 repo.release_review_worktree(934);
8569
8570 assert_eq!(
8571 "TOKEN=keep me\n",
8572 std::fs::read_to_string(path.join(".env.local")).unwrap()
8573 );
8574 }
8575
8576 #[test]
8577 fn a_repository_nested_in_build_output_keeps_a_worktree() {
8578 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
8579 exclude_paths(&repo, &["node_modules/"]);
8580 let nested = path.join("node_modules/local-dep");
8581 std::fs::create_dir_all(&nested).unwrap();
8582 test_git(&nested, &["init"]);
8583 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
8584
8585 assert!(repository_has_recoverable_work(&path, true).unwrap());
8586 repo.release_review_worktree(935);
8587
8588 assert!(nested.join(".git").exists());
8589 }
8590
8591 #[test]
8592 fn an_ordinary_untracked_file_keeps_a_worktree() {
8593 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
8594 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
8595
8596 assert!(repository_has_recoverable_work(&path, true).unwrap());
8597 repo.release_review_worktree(936);
8598
8599 assert_eq!(
8600 "somebody's notes\n",
8601 std::fs::read_to_string(path.join("notes.md")).unwrap()
8602 );
8603 }
8604
8605 #[test]
8606 fn a_legacy_crlf_checkout_is_retained_conservatively() {
8607 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
8608 std::fs::write(
8609 path.join(".gitattributes"),
8610 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
8611 )
8612 .unwrap();
8613 test_git(&path, &["add", ".gitattributes"]);
8614 test_git(&path, &["commit", "-m", "select legacy line endings"]);
8615
8616 assert!(repository_has_recoverable_work(&path, true).unwrap());
8617 }
8618
8619 #[test]
8620 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
8621 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
8622 let nested = path.join("tracked");
8623 std::fs::create_dir_all(&nested).unwrap();
8624 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
8625 test_git(&path, &["add", "tracked/seed.txt"]);
8626 test_git(&path, &["commit", "-m", "add tracked directory"]);
8627 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8628 test_git(&nested, &["init"]);
8629
8630 let error = repo
8631 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8632 .unwrap_err();
8633
8634 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8635 assert!(error.to_string().contains("Git entry"), "{error}");
8636 assert!(nested.join(".git").exists());
8637 }
8638
8639 #[test]
8644 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
8645 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
8646 exclude_paths(&repo, &["target/"]);
8647 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8648 let repro = path.join("target/pr918-repro");
8649 std::fs::create_dir_all(&repro).unwrap();
8650 test_git(&repro, &["init"]);
8651 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
8652
8653 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8654 .unwrap();
8655
8656 assert!(repository_has_recoverable_work(&path, true).unwrap());
8657 assert!(repro.join(".git").exists());
8658 }
8659
8660 #[test]
8664 fn a_repository_inside_a_nested_checkout_is_left_alone() {
8665 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
8666 exclude_paths(&repo, &["target/"]);
8667 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8668 let repro = path.join("target/repro");
8669 let vendored = repro.join("vendor/dep");
8670 std::fs::create_dir_all(&vendored).unwrap();
8671 test_git(&repro, &["init"]);
8672 test_git(&vendored, &["init"]);
8673
8674 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8675 .unwrap();
8676
8677 assert!(vendored.join(".git").exists());
8678 }
8679
8680 #[test]
8681 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
8682 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
8683
8684 let state = ignored_untracked_state(repo.root()).unwrap();
8685
8686 let relative = path.strip_prefix(repo.root()).unwrap();
8687 assert!(
8688 state.files.contains_key(relative),
8689 "{:?}",
8690 state.files.keys().collect::<Vec<_>>()
8691 );
8692 assert!(state.is_ignored(relative));
8693 }
8694
8695 #[test]
8696 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
8697 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
8698 let baseline = repo.worktree_baseline(repo.root()).unwrap();
8699 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
8700 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
8701
8702 repo.refuse_new_ignored_files(repo.root(), &baseline)
8703 .unwrap();
8704 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
8705 .unwrap();
8706 }
8707
8708 #[test]
8709 fn deleting_a_resident_worktree_during_a_call_is_refused() {
8710 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
8711 let baseline = repo.worktree_baseline(repo.root()).unwrap();
8712 std::fs::remove_dir_all(&path).unwrap();
8713
8714 let error = repo
8715 .refuse_new_ignored_files(repo.root(), &baseline)
8716 .unwrap_err();
8717
8718 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8719 assert!(error.to_string().contains("review-932"), "{error}");
8720 }
8721
8722 #[test]
8723 fn a_nested_repository_record_is_read_as_a_plain_path() {
8724 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
8725 assert_eq!(Path::new("vendor/checkout"), path);
8726 assert!(nested);
8727
8728 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
8729 assert_eq!(Path::new("vendor/notes.txt"), path);
8730 assert!(!nested);
8731
8732 assert!(untracked_record(b"/", "untracked").is_err());
8733 }
8734
8735 #[cfg(unix)]
8736 #[test]
8737 fn a_non_utf8_git_path_is_preserved_without_loss() {
8738 use std::os::unix::ffi::OsStrExt;
8739
8740 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
8741
8742 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
8743 }
8744
8745 #[test]
8746 fn guarded_merge_pins_the_reviewed_head() {
8747 let args = merge_pr_args("36", Some("abc123"), true);
8748 assert_eq!(
8749 vec![
8750 "pr",
8751 "merge",
8752 "36",
8753 "--squash",
8754 "--delete-branch",
8755 "--match-head-commit",
8756 "abc123"
8757 ],
8758 args
8759 );
8760 }
8761
8762 #[test]
8763 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
8764 let pr = PrRef {
8765 number: 7,
8766 url: "https://example.test/pull/7".into(),
8767 title: "part one".into(),
8768 };
8769 let result = reconcile_pr_creation(
8770 "split-34-1",
8771 Err(crate::error::SparError::new("connection lost")),
8772 Ok(Some(pr)),
8773 )
8774 .unwrap();
8775 assert_eq!(7, result.number);
8776 }
8777
8778 #[test]
8779 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
8780 let error = reconcile_pr_creation(
8781 "split-34-1",
8782 Err(crate::error::SparError::new("permission denied")),
8783 Ok(None),
8784 )
8785 .unwrap_err();
8786 assert!(error.to_string().contains("permission denied"), "{error}");
8787 }
8788
8789 #[test]
8790 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
8791 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
8792 assert!(pr_for_base(text, "split-34-2", "split-34-1")
8793 .unwrap()
8794 .is_none());
8795 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
8796 assert_eq!(7, found.number);
8797 }
8798
8799 #[test]
8800 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
8801 let result = reconcile_comment_post(
8802 34,
8803 "the summary",
8804 crate::error::SparError::new("connection lost"),
8805 Ok(vec![serde_json::json!({"body": "the summary"})]),
8806 );
8807 assert!(result.is_ok(), "{result:?}");
8808 }
8809
8810 #[test]
8811 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
8812 let error = reconcile_comment_post(
8813 34,
8814 "the summary",
8815 crate::error::SparError::new("connection lost"),
8816 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
8817 )
8818 .unwrap_err();
8819 assert_eq!("connection lost", error.to_string());
8820 }
8821
8822 #[test]
8823 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
8824 let error = reconcile_comment_post(
8825 34,
8826 "the summary",
8827 crate::error::SparError::new("connection lost"),
8828 Err(crate::error::SparError::new("comments unavailable")),
8829 )
8830 .unwrap_err();
8831 assert!(
8832 error.to_string().contains("could not be verified"),
8833 "{error}"
8834 );
8835 assert!(
8836 error.to_string().contains("comments unavailable"),
8837 "{error}"
8838 );
8839 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8840 assert!(!error.worth_retrying());
8841 }
8842
8843 #[test]
8844 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
8845 let result = reconcile_issue_edit(
8846 34,
8847 "wanted body",
8848 crate::error::SparError::new("connection lost"),
8849 Ok("wanted body".to_string()),
8850 );
8851 assert!(result.is_ok(), "{result:?}");
8852 }
8853
8854 #[test]
8855 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
8856 let error = reconcile_issue_edit(
8857 34,
8858 "wanted body",
8859 crate::error::SparError::new("connection lost"),
8860 Err(crate::error::SparError::new("issue unavailable")),
8861 )
8862 .unwrap_err();
8863 assert!(
8864 error.to_string().contains("could not be verified"),
8865 "{error}"
8866 );
8867 assert!(error.to_string().contains("issue unavailable"), "{error}");
8868 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8869 assert!(!error.worth_retrying());
8870 }
8871
8872 #[test]
8873 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
8874 let found = ExistingIssue {
8875 number: 101,
8876 url: "https://example.test/issues/101".into(),
8877 title: "child".into(),
8878 body: "body".into(),
8879 open: true,
8880 };
8881 let url = reconcile_issue_creation(
8882 "child",
8883 Err(crate::error::SparError::new("connection lost")),
8884 Ok(Some(found)),
8885 )
8886 .unwrap();
8887 assert_eq!("https://example.test/issues/101", url);
8888 }
8889
8890 #[test]
8891 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8892 let error = reconcile_issue_creation(
8893 "child",
8894 Err(crate::error::SparError::new("permission denied")),
8895 Ok(None),
8896 )
8897 .unwrap_err();
8898 assert!(error.to_string().contains("permission denied"), "{error}");
8899 }
8900
8901 #[test]
8902 fn an_unverifiable_issue_creation_is_marked_uncertain() {
8903 let error = reconcile_issue_creation(
8904 "child",
8905 Err(crate::error::SparError::new("connection lost")),
8906 Err(crate::error::SparError::new("issues unavailable")),
8907 )
8908 .unwrap_err();
8909 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8910 assert!(!error.worth_retrying());
8911 }
8912
8913 #[test]
8914 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8915 let result = reconcile_failed_split_push(
8916 "split-34-1",
8917 crate::error::SparError::new("connection lost"),
8918 Ok("abc123\n".into()),
8919 Ok("abc123\trefs/heads/split-34-1\n".into()),
8920 );
8921 assert!(result.is_ok(), "{result:?}");
8922 }
8923
8924 #[test]
8925 fn a_split_push_collision_is_definite_and_never_overwrites() {
8926 let error = reconcile_failed_split_push(
8927 "split-34-1",
8928 crate::error::SparError::new("lease rejected"),
8929 Ok("abc123\n".into()),
8930 Ok("def456\trefs/heads/split-34-1\n".into()),
8931 )
8932 .unwrap_err();
8933 assert!(!error.retain_worktree());
8934 assert!(
8935 error.to_string().contains("Nothing was overwritten"),
8936 "{error}"
8937 );
8938 }
8939
8940 #[test]
8941 fn an_unreadable_split_push_result_keeps_the_worktree() {
8942 let error = reconcile_failed_split_push(
8943 "split-34-1",
8944 crate::error::SparError::new("connection lost"),
8945 Ok("abc123\n".into()),
8946 Err(crate::error::SparError::new("origin unavailable")),
8947 )
8948 .unwrap_err();
8949 assert!(error.retain_worktree());
8950 assert!(error.to_string().contains("could not confirm"), "{error}");
8951 }
8952
8953 #[test]
8957 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8958 let repo = repo_for_titles();
8959 for raw in [
8960 "Retry loop spins \u{2014} Retry-After parses to zero",
8961 "plain title",
8962 " spread over\nlines ",
8963 "\u{1F916} Generated with something",
8964 &format!("a \u{2014} {}", "very long title ".repeat(20)),
8965 &"x".repeat(300),
8966 &format!("{} \u{2014} end", "y".repeat(88)),
8967 &{
8972 let tail = "a\u{2014}b c\u{2014}d";
8973 let pad = Style::default().max_title_chars - tail.chars().count();
8974 format!("{}{tail}", "w".repeat(pad))
8975 },
8976 ] {
8977 let once = repo.clean_title(raw).unwrap();
8978 let twice = repo.clean_title(&once).unwrap();
8979 assert_eq!(once, twice, "not idempotent for {raw:?}");
8980 assert!(
8981 once.chars().count() <= repo.style.max_title_chars,
8982 "over budget: {once:?}"
8983 );
8984 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8985 }
8986 }
8987
8988 #[test]
8989 fn a_title_with_an_em_dash_survives_as_readable_text() {
8990 let repo = repo_for_titles();
8991 assert_eq!(
8992 "Retry loop spins, Retry-After parses to zero",
8993 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8994 .unwrap()
8995 );
8996 }
8997
8998 #[test]
8999 fn sh_quote_survives_a_quote() {
9000 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
9001 }
9002
9003 #[test]
9004 fn sh_quote_wraps_a_space() {
9005 assert_eq!(
9006 "'/Applications/My App/spar'",
9007 sh_quote("/Applications/My App/spar")
9008 );
9009 }
9010
9011 #[test]
9012 fn finished_states_are_recognised_case_insensitively() {
9013 assert!(is_finished("MERGED"));
9014 assert!(is_finished("closed"));
9015 assert!(!is_finished("OPEN"));
9016 assert!(!is_finished(""));
9017 }
9018
9019 fn state() -> PersistedState {
9020 PersistedState {
9021 version: 1,
9022 checkpoint: 0,
9023 round: 4,
9024 next_actor: "codex".into(),
9025 status: Status::Pending,
9026 pr_head: "abc123".into(),
9027 ledger: Ledger::new(),
9028 filed: vec![],
9029 open_findings: vec![Finding {
9030 severity: Severity::Blocking,
9031 title: "Unchecked error".into(),
9032 detail: "the failure is discarded".into(),
9033 file: "src/a.rs:12".into(),
9034 ..Finding::default()
9035 }],
9036 disputes: vec![Dispute {
9037 title: "Retry limit".into(),
9038 file: "src/net.rs".into(),
9039 reasoning: "the caller already bounds it".into(),
9040 }],
9041 noted: vec![Finding {
9042 severity: Severity::NonBlocking,
9043 title: "Timeout is fixed".into(),
9044 file: "src/config.rs".into(),
9045 ..Finding::default()
9046 }],
9047 }
9048 }
9049
9050 #[test]
9051 fn a_state_comment_round_trips() {
9052 let body = format!(
9053 "{STATE_MARKER}\n{}\n-->",
9054 serde_json::to_string(&state()).unwrap()
9055 );
9056 let back = parse_state_comment(&body).unwrap();
9057 assert_eq!(4, back.round);
9058 assert_eq!("codex", back.next_actor);
9059 assert_eq!("abc123", back.pr_head);
9060 assert_eq!("Unchecked error", back.open_findings[0].title);
9061 assert_eq!("src/net.rs", back.disputes[0].file);
9062 assert_eq!("Timeout is fixed", back.noted[0].title);
9063 }
9064
9065 #[test]
9066 fn old_state_without_new_lists_still_parses() {
9067 let body = format!(
9068 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
9069 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9070 );
9071 let back = parse_state_comment(&body).expect("old state");
9072 assert!(back.open_findings.is_empty());
9073 assert!(back.disputes.is_empty());
9074 assert!(back.noted.is_empty());
9075 assert!(back.pr_head.is_empty());
9076 assert_eq!(0, back.checkpoint);
9077 }
9078
9079 #[test]
9080 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
9081 let mut local = state();
9082 local.pr_head = "old".into();
9083 local.round = 9;
9084 let mut remote = state();
9085 remote.pr_head = "current".into();
9086 remote.round = 4;
9087
9088 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9089 assert_eq!("current", chosen.pr_head);
9090 assert_eq!(4, chosen.round);
9091 }
9092
9093 #[test]
9094 fn checkpoint_order_breaks_same_round_ties() {
9095 let mut local = state();
9096 local.pr_head = "current".into();
9097 local.round = 4;
9098 local.checkpoint = 8;
9099 let mut remote = local.clone();
9100 remote.checkpoint = 7;
9101 remote.open_findings.clear();
9102
9103 let chosen = choose_state_for_head(vec![local], "current").unwrap();
9104 assert_eq!(8, chosen.checkpoint);
9105
9106 let mut local = state();
9107 local.pr_head = "current".into();
9108 local.round = 4;
9109 local.checkpoint = 8;
9110 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
9111 assert_eq!(8, chosen.checkpoint);
9112 }
9113
9114 #[test]
9115 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
9116 let mut local = state();
9117 local.pr_head = "current".into();
9118 local.round = 4;
9119 local.open_findings.push(Finding {
9120 title: "local checkpoint".into(),
9121 ..Finding::default()
9122 });
9123 let mut remote = state();
9124 remote.pr_head = "current".into();
9125 remote.round = 4;
9126
9127 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9128 assert_eq!(
9129 "local checkpoint",
9130 chosen.open_findings.last().unwrap().title
9131 );
9132 }
9133
9134 #[test]
9136 fn the_state_block_is_an_html_comment() {
9137 let body = format!(
9138 "{STATE_MARKER}\n{}\n-->",
9139 serde_json::to_string(&state()).unwrap()
9140 );
9141 assert!(body.starts_with("<!--"));
9142 assert!(body.trim_end().ends_with("-->"));
9143 assert!(!body[..body.find('{').unwrap()].contains("-->"));
9144 }
9145
9146 #[test]
9147 fn an_unrelated_json_block_is_not_state() {
9148 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
9149 }
9150
9151 #[test]
9158 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
9159 let block = format!(
9160 "{STATE_MARKER}\n{}\n-->",
9161 serde_json::to_string(&state()).unwrap()
9162 );
9163 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
9164 assert!(
9165 is_state_comment(&block, "Spar-Bot", "spar-bot"),
9166 "a login differs only in case"
9167 );
9168
9169 assert!(
9170 !is_state_comment(
9171 "why is there a `<!-- spar:state` block on this PR?",
9172 "maintainer",
9173 "spar-bot"
9174 ),
9175 "a question about the block was treated as the block"
9176 );
9177 assert!(
9178 !is_state_comment(&block, "maintainer", "spar-bot"),
9179 "somebody else's comment is not spar's to rewrite"
9180 );
9181 assert!(
9182 !is_state_comment(
9183 &format!("here is what spar writes:\n\n{block}"),
9184 "spar-bot",
9185 "spar-bot"
9186 ),
9187 "the marker has to open the body, not appear in it"
9188 );
9189 assert!(
9190 !is_state_comment(
9191 &format!("{STATE_MARKER}\n{{not json\n-->"),
9192 "spar-bot",
9193 "spar-bot"
9194 ),
9195 "a block that does not parse is not state"
9196 );
9197 assert!(
9198 !is_state_comment(&block, "spar-bot", ""),
9199 "an unknown viewer proves nothing about who wrote it"
9200 );
9201 }
9202
9203 #[test]
9204 fn a_malformed_state_comment_is_none_not_a_panic() {
9205 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
9206 }
9207
9208 #[test]
9209 fn atomic_write_leaves_no_temp_file() {
9210 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
9211 let _ = std::fs::remove_dir_all(&dir);
9212 let path = dir.join("state").join("pr-7.json");
9213 write_json_atomic(&path, &state()).unwrap();
9214 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
9215 .unwrap()
9216 .flatten()
9217 .filter_map(|e| e.file_name().to_str().map(str::to_string))
9218 .collect();
9219 assert_eq!(vec!["pr-7.json".to_string()], files);
9220 let _ = std::fs::remove_dir_all(&dir);
9221 }
9222
9223 #[test]
9224 fn atomic_write_overwrites_rather_than_accumulating() {
9225 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
9226 let _ = std::fs::remove_dir_all(&dir);
9227 let path = dir.join("pr-7.json");
9228 for round in 1..4 {
9229 let mut s = state();
9230 s.round = round;
9231 write_json_atomic(&path, &s).unwrap();
9232 }
9233 let back: PersistedState =
9234 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9235 assert_eq!(3, back.round);
9236 let _ = std::fs::remove_dir_all(&dir);
9237 }
9238
9239 #[test]
9240 fn style_from_env_defaults_to_enforcing() {
9241 std::env::remove_var("SPAR_BAN_EM_DASH");
9242 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
9243 let style = style_from_env();
9244 assert!(style.ban_em_dash && style.ban_ai_attribution);
9245 assert!(
9246 !style.terse,
9247 "the commit filter must not truncate a commit message"
9248 );
9249 }
9250}
9251
9252#[cfg(test)]
9253mod comment_page_tests {
9254 use super::*;
9255
9256 #[test]
9257 fn a_single_merged_array_is_read() {
9258 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
9259 assert_eq!(2, pages.len());
9260 assert_eq!(Some(2), pages[1]["id"].as_i64());
9261 }
9262
9263 #[test]
9264 fn concatenated_pages_from_an_older_gh_are_read_too() {
9265 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
9266 assert_eq!(2, pages.len());
9267 }
9268
9269 #[test]
9273 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
9274 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
9275 let pages = parse_comment_pages(text);
9276 assert_eq!(2, pages.len(), "{pages:?}");
9277 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
9278 }
9279
9280 #[test]
9281 fn empty_output_is_no_comments_not_a_panic() {
9282 assert!(parse_comment_pages("").is_empty());
9283 assert!(parse_comment_pages(" ").is_empty());
9284 assert!(parse_comment_pages("[]").is_empty());
9285 }
9286
9287 #[test]
9288 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
9289 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
9290 }
9291
9292 #[test]
9293 fn a_write_postcheck_rejects_truncated_comment_pages() {
9294 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
9295 assert!(
9296 error.to_string().contains("unexpected comment pages"),
9297 "{error}"
9298 );
9299 }
9300
9301 #[test]
9302 fn a_write_postcheck_rejects_empty_or_non_array_output() {
9303 assert!(try_parse_comment_pages("").is_err());
9304 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
9305 assert!(try_parse_comment_pages("[]").is_ok());
9306 }
9307
9308 #[test]
9309 fn state_is_found_in_the_last_matching_comment() {
9310 let payload = |round: u32| {
9311 format!(
9312 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9313 )
9314 };
9315 let text = serde_json::to_string(&serde_json::json!([
9316 {"id": 1, "body": payload(1)},
9317 {"id": 2, "body": "looks good to me"},
9318 {"id": 3, "body": payload(5)},
9319 ]))
9320 .unwrap();
9321 let pages = parse_comment_pages(&text);
9322 let last = pages
9323 .iter()
9324 .rev()
9325 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
9326 .unwrap();
9327 assert_eq!(5, last.round);
9328 }
9329}
9330
9331#[cfg(test)]
9332mod linked_pr_tests {
9333 use super::*;
9334
9335 const REAL_PAYLOAD: &str = r#"[
9340 {"number":14252,"title":"fix: reject leading-dash branch names",
9341 "url":"https://github.com/cli/cli/pull/14252",
9342 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
9343 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9344 "url":"https://github.com/cli/cli/issues/14238"}]},
9345 {"number":14217,"title":"another change",
9346 "url":"https://github.com/cli/cli/pull/14217",
9347 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
9348 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9349 "url":"https://github.com/cli/cli/issues/9761"}]},
9350 {"number":14200,"title":"unlinked work",
9351 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
9352 ]"#;
9353
9354 #[test]
9355 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
9356 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
9357 assert_eq!(14252, pr.number);
9358 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
9359 }
9360
9361 #[test]
9362 fn the_right_pr_is_picked_out_of_several() {
9363 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
9364 }
9365
9366 #[test]
9367 fn an_issue_nobody_is_working_on_finds_nothing() {
9368 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
9369 }
9370
9371 #[test]
9372 fn an_unlinked_pr_is_never_matched() {
9373 for issue in [14200, 0, 1] {
9375 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
9376 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
9377 }
9378 }
9379 }
9380
9381 #[test]
9382 fn empty_or_broken_output_is_none_rather_than_a_panic() {
9383 assert!(find_linked_pr("", 1).is_none());
9384 assert!(find_linked_pr("[]", 1).is_none());
9385 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
9386 assert!(find_linked_pr("[{\"number\":", 1).is_none());
9387 }
9388
9389 #[test]
9396 fn an_unreadable_list_is_not_an_empty_list() {
9397 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
9398 assert!(linked_pr_rows("[{\"number\":").is_none());
9399 assert!(linked_pr_rows("").is_none());
9400
9401 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
9402 assert!(empty.is_empty());
9403 assert!(pick_linked_pr(&empty, 14238).is_none());
9404
9405 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
9406 assert_eq!(3, listed.len());
9407 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
9408 }
9409
9410 #[test]
9412 fn pr_view_reads_the_cross_repository_flag() {
9413 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
9414 "baseRefName":"main","state":"OPEN",
9415 "closingIssuesReferences":[],"isCrossRepository":true}"#;
9416 let pr: PrView = serde_json::from_str(json).unwrap();
9417 assert!(pr.is_cross_repository);
9418 assert!(pr.is_open());
9419
9420 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
9421 assert!(
9422 !serde_json::from_str::<PrView>(&same_repo)
9423 .unwrap()
9424 .is_cross_repository
9425 );
9426 }
9427}
9428
9429#[cfg(test)]
9430mod min_number_tests {
9431 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
9437 let mut numbers: Vec<i64> = open.to_vec();
9438 numbers.sort_unstable();
9439 if min_number > 0 {
9440 numbers.retain(|n| *n >= min_number);
9441 }
9442 numbers.truncate(limit);
9443 numbers
9444 }
9445
9446 #[test]
9447 fn the_floor_is_applied_before_the_cap_not_after() {
9448 let open = [12, 13, 14, 480, 481, 482];
9449 assert_eq!(vec![480, 481], pick(&open, 2, 480));
9450 assert!(!pick(&open, 2, 480).is_empty());
9453 }
9454
9455 #[test]
9456 fn no_floor_keeps_the_old_behaviour() {
9457 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
9458 }
9459
9460 #[test]
9461 fn the_floor_is_inclusive() {
9462 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
9463 }
9464
9465 #[test]
9466 fn a_floor_above_everything_open_yields_nothing() {
9467 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
9468 }
9469}