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