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