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