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, LiveHead, PersistedState, PrRef,
21 PrRow, PrSweepRow, PrView, PR_SWEEP_FIELDS,
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 is_desktop_metadata(path: &Path) -> bool {
804 path.file_name() == Some(OsStr::new(".DS_Store"))
805}
806
807fn merge_pr_args<'a>(
808 number: &'a str,
809 expected_head: Option<&'a str>,
810 delete_branch: bool,
811) -> Vec<&'a str> {
812 let mut args = vec!["pr", "merge", number, "--squash"];
813 if delete_branch {
814 args.push("--delete-branch");
815 }
816 if let Some(expected_head) = expected_head {
817 args.extend(["--match-head-commit", expected_head]);
818 }
819 args
820}
821
822fn reconcile_pr_creation(
823 branch: &str,
824 created: Result<String>,
825 found: Result<Option<PrRef>>,
826) -> Result<PrRef> {
827 match (created, found) {
828 (_, Ok(Some(pr))) => Ok(pr),
829 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
830 "PR creation reported success but none was found for {branch}"
831 ))),
832 (Err(create), Ok(None)) => Err(spar_err!(
833 "could not open a PR for {branch}. {}",
834 create.last_line()
835 )),
836 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
837 "PR creation reported success for {branch}, but it could not be verified. {}",
838 check.last_line()
839 ))),
840 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
841 "could not open a PR for {branch}. {} The result could not be verified: {}",
842 create.last_line(),
843 check.last_line()
844 ))),
845 }
846}
847
848fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
849 #[derive(Deserialize)]
850 #[serde(rename_all = "camelCase")]
851 struct Row {
852 number: i64,
853 #[serde(default)]
854 url: String,
855 #[serde(default)]
856 title: String,
857 base_ref_name: String,
858 }
859
860 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
861 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
862 })?;
863 Ok(rows
864 .into_iter()
865 .find(|row| row.base_ref_name == base)
866 .map(|row| PrRef {
867 number: row.number,
868 url: row.url,
869 title: row.title,
870 }))
871}
872
873fn signed(body: &str) -> String {
876 if body.contains(COMMENT_MARKER) {
877 return body.to_string();
878 }
879 format!("{body}\n\n{COMMENT_MARKER}")
880}
881
882pub fn is_spar_comment(body: &str) -> bool {
884 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
885}
886
887#[derive(Debug, Clone, Default, PartialEq, Eq)]
889pub struct ApiResponse {
890 pub status: u16,
891 pub headers: BTreeMap<String, String>,
893 pub body: String,
894}
895
896pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
900 let mut lines = stdout.split_inclusive('\n');
901 let first = lines.next()?.trim_end_matches(['\r', '\n']);
902 if !first.starts_with("HTTP/") {
903 return None;
904 }
905 let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
906 let mut headers = BTreeMap::new();
907 let mut body = String::new();
908 let mut in_body = false;
909 for line in lines {
910 if in_body {
911 body.push_str(line);
912 continue;
913 }
914 let bare = line.trim_end_matches(['\r', '\n']);
915 if bare.is_empty() {
916 in_body = true;
917 continue;
918 }
919 if let Some((name, value)) = bare.split_once(':') {
920 headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
921 }
922 }
923 Some(ApiResponse {
924 status,
925 headers,
926 body,
927 })
928}
929
930const REQUESTS_QUERY: &str = "\
933query($owner: String!, $repo: String!, $number: Int!) {
934 repository(owner: $owner, name: $repo) {
935 pullRequest(number: $number) {
936 timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
937 nodes {
938 ... on ReviewRequestedEvent {
939 createdAt
940 requestedReviewer {
941 ... on User { login }
942 ... on Team { combinedSlug }
943 }
944 }
945 }
946 }
947 }
948 }
949}";
950
951pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
955 let v: Value = serde_json::from_str(text).ok()?;
956 let nodes = v
957 .pointer("/data/repository/pullRequest/timelineItems/nodes")?
958 .as_array()?;
959 let text_at = |node: &Value, path: &str| {
960 node.pointer(path)
961 .and_then(Value::as_str)
962 .unwrap_or_default()
963 .to_string()
964 };
965 Some(
966 nodes
967 .iter()
968 .map(|node| crate::model::ReviewRequested {
969 at: text_at(node, "/createdAt"),
970 who: crate::model::Login {
971 login: text_at(node, "/requestedReviewer/login"),
972 slug: text_at(node, "/requestedReviewer/combinedSlug"),
973 },
974 })
975 .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
976 .collect(),
977 )
978}
979
980pub(crate) fn state_file_number(name: &str) -> Option<i64> {
984 let stem = name.strip_suffix(".json")?;
985 ["pr-", "review-", "checkin-"]
986 .iter()
987 .find_map(|prefix| stem.strip_prefix(prefix))
988 .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
989 .and_then(|digits| digits.parse().ok())
990}
991
992fn has_viewer_review(reviews: &[Value], viewer: &str, body: &str, commit: &str) -> bool {
997 reviews.iter().any(|review| {
998 let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
999 let standing = !matches!(
1000 field("state").to_uppercase().as_str(),
1001 "DISMISSED" | "PENDING"
1002 );
1003 review
1004 .get("user")
1005 .and_then(|u| u.get("login"))
1006 .and_then(Value::as_str)
1007 .is_some_and(|login| crate::comments::same_login(login, viewer))
1008 && field("body") == body
1009 && field("commit_id").eq_ignore_ascii_case(commit)
1010 && standing
1011 })
1012}
1013
1014fn reconcile_review_post(
1015 number: i64,
1016 body: &str,
1017 viewer: &str,
1018 commit: &str,
1019 post_error: crate::error::SparError,
1020 reviews: Result<Vec<Value>>,
1021) -> Result<()> {
1022 match reviews {
1023 Ok(reviews) if has_viewer_review(&reviews, viewer, body, commit) => Ok(()),
1024 Ok(_) => Err(post_error),
1025 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1026 "could not submit a review on #{number}. {} The result could not be verified: {}",
1027 post_error.last_line(),
1028 read_error.last_line()
1029 ))),
1030 }
1031}
1032
1033fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1034 comments.iter().any(|comment| {
1035 comment
1036 .get("body")
1037 .and_then(Value::as_str)
1038 .is_some_and(|seen| seen == body)
1039 })
1040}
1041
1042fn reconcile_comment_post(
1043 number: i64,
1044 body: &str,
1045 post_error: crate::error::SparError,
1046 comments: Result<Vec<Value>>,
1047) -> Result<()> {
1048 match comments {
1049 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1050 Ok(_) => Err(post_error),
1051 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1052 "could not comment on #{number}. {} The result could not be verified: {}",
1053 post_error.last_line(),
1054 read_error.last_line()
1055 ))),
1056 }
1057}
1058
1059fn reconcile_issue_edit(
1060 number: i64,
1061 wanted: &str,
1062 edit_error: crate::error::SparError,
1063 observed: Result<String>,
1064) -> Result<()> {
1065 match observed {
1066 Ok(body) if body == wanted => Ok(()),
1067 Ok(_) => Err(spar_err!(
1068 "could not rewrite the body of #{number}. {}",
1069 edit_error.last_line()
1070 )),
1071 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1072 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1073 edit_error.last_line(),
1074 read_error.last_line()
1075 ))),
1076 }
1077}
1078
1079fn issue_url_has_number(url: &str) -> bool {
1080 url.trim()
1081 .rsplit('/')
1082 .next()
1083 .and_then(|tail| tail.parse::<i64>().ok())
1084 .is_some_and(|number| number > 0)
1085}
1086
1087fn reconcile_issue_creation(
1088 title: &str,
1089 created: Result<String>,
1090 found: Result<Option<ExistingIssue>>,
1091) -> Result<String> {
1092 match (created, found) {
1093 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1094 (_, Ok(Some(issue))) => Ok(issue.url),
1095 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1096 "issue creation reported success but no matching issue was found for {title:?}"
1097 ))),
1098 (Err(create), Ok(None)) => Err(spar_err!(
1099 "could not file issue {title:?}. {}",
1100 create.last_line()
1101 )),
1102 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1103 "issue creation reported success for {title:?}, but it could not be verified. {}",
1104 check.last_line()
1105 ))),
1106 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1107 "could not file issue {title:?}. {} The result could not be verified: {}",
1108 create.last_line(),
1109 check.last_line()
1110 ))),
1111 }
1112}
1113
1114fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1115 if output.trim().is_empty() {
1116 return Ok(None);
1117 }
1118 for line in output.lines() {
1119 let mut fields = line.split_whitespace();
1120 let oid = fields.next().unwrap_or_default();
1121 let name = fields.next().unwrap_or_default();
1122 if name == remote_ref && !oid.is_empty() {
1123 return Ok(Some(oid.to_string()));
1124 }
1125 }
1126 Err(spar_err!(
1127 "origin returned an unexpected ref listing for {remote_ref}"
1128 ))
1129}
1130
1131fn reconcile_failed_split_push(
1132 branch: &str,
1133 push_error: crate::error::SparError,
1134 local: Result<String>,
1135 remote: Result<String>,
1136) -> std::result::Result<(), SplitPushError> {
1137 let remote_ref = format!("refs/heads/{branch}");
1138 match (local, remote) {
1139 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1140 Ok(Some(oid)) if oid == local.trim() => Ok(()),
1141 Ok(_) => Err(SplitPushError::new(
1142 format!(
1143 "could not create origin/{branch}. {} The remote branch is absent or points \
1144 somewhere else. Nothing was overwritten.",
1145 push_error.last_line()
1146 ),
1147 false,
1148 )),
1149 Err(check) => Err(SplitPushError::new(
1150 format!(
1151 "could not confirm whether origin/{branch} was created. {} The remote result \
1152 could not be verified: {}",
1153 push_error.last_line(),
1154 check.last_line()
1155 ),
1156 true,
1157 )),
1158 },
1159 (local, remote) => {
1160 let check = match (local, remote) {
1161 (Err(local), Err(remote)) => format!(
1162 "the local commit could not be read: {}; origin could not be read: {}",
1163 local.last_line(),
1164 remote.last_line()
1165 ),
1166 (Err(local), _) => {
1167 format!("the local commit could not be read: {}", local.last_line())
1168 }
1169 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1170 _ => unreachable!(),
1171 };
1172 Err(SplitPushError::new(
1173 format!(
1174 "could not confirm whether origin/{branch} was created. {} The result could \
1175 not be verified because {check}",
1176 push_error.last_line()
1177 ),
1178 true,
1179 ))
1180 }
1181 }
1182}
1183
1184impl Repo {
1185 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1186 let root =
1187 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1188 let inside = proc::run_str(
1191 &["git", "rev-parse", "--is-inside-work-tree"],
1192 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1193 )
1194 .unwrap_or_default();
1195 if inside.trim() != "true" {
1196 bail!("not a git repository: {}", root.display());
1197 }
1198 let repo = Self {
1199 root,
1200 style: cfg.style.clone(),
1201 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1202 state_store: cfg.loop_cfg.state_store,
1203 followups: cfg.loop_cfg.followups,
1204 drafts: cfg.loop_cfg.drafts,
1205 viewer: OnceLock::new(),
1206 checkpoints: Mutex::new(BTreeMap::new()),
1207 writes: WriteStats::default(),
1208 };
1209 repo.self_exclude();
1210 Ok(repo)
1211 }
1212
1213 fn self_exclude(&self) {
1221 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1222 let git_dir = git_dir.trim();
1223 if git_dir.is_empty() {
1224 return;
1225 }
1226 let path = Path::new(git_dir).join("info").join("exclude");
1227 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1228
1229 let wanted = [
1234 format!("/{WORKTREE_DIR}/"),
1235 format!("/{STATE_DIR}/"),
1236 format!("{RECOVERY_MARKER_PREFIX}*"),
1237 ];
1238 let missing: Vec<&String> = wanted
1239 .iter()
1240 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1241 .collect();
1242 if missing.is_empty() {
1243 return;
1244 }
1245
1246 use std::io::Write;
1247 if let Some(parent) = path.parent() {
1248 let _ = std::fs::create_dir_all(parent);
1249 }
1250 let mut block = String::new();
1251 if !existing.is_empty() && !existing.ends_with('\n') {
1252 block.push('\n');
1253 }
1254 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1255 for line in missing {
1256 block.push_str(line);
1257 block.push('\n');
1258 }
1259 if let Ok(mut file) = std::fs::OpenOptions::new()
1260 .create(true)
1261 .append(true)
1262 .open(&path)
1263 {
1264 let _ = file.write_all(block.as_bytes());
1265 }
1266 }
1267
1268 pub fn root(&self) -> &Path {
1269 &self.root
1270 }
1271
1272 pub(crate) fn write_summary(&self) -> WriteSummary {
1273 WriteSummary {
1274 attempted: self.writes.attempted.load(Ordering::Relaxed),
1275 failed: self.writes.failed.load(Ordering::Relaxed),
1276 }
1277 }
1278
1279 pub(crate) fn record_write<T, E>(
1280 &self,
1281 result: std::result::Result<T, E>,
1282 ) -> std::result::Result<T, E> {
1283 self.record_write_outcome(result.is_err());
1284 result
1285 }
1286
1287 pub(crate) fn record_failed_write<T, E>(
1288 &self,
1289 result: std::result::Result<T, E>,
1290 ) -> std::result::Result<T, E> {
1291 if result.is_err() {
1292 self.record_write_outcome(true);
1293 }
1294 result
1295 }
1296
1297 fn record_write_outcome(&self, failed: bool) {
1298 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1299 if failed {
1300 self.writes.failed.fetch_add(1, Ordering::Relaxed);
1301 }
1302 }
1303
1304 pub fn clean(&self, text: &str) -> Result<String> {
1310 let out = style::scrub(text, &self.style);
1311 let bad = style::violations(&out, &self.style);
1312 if !bad.is_empty() {
1313 bail!(
1314 "style gate could not clean text ({}): {}",
1315 bad.join(", "),
1316 style::clip(&out, 300)
1317 );
1318 }
1319 Ok(out)
1320 }
1321
1322 pub fn clean_body(&self, text: &str) -> Result<String> {
1324 self.clean(&style::body(text, &self.style))
1325 }
1326
1327 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1329 self.clean(&style::issue_body(text, &self.style))
1330 }
1331
1332 pub fn clean_title(&self, text: &str) -> Result<String> {
1343 Ok(style::title(&self.clean(text)?, &self.style))
1344 }
1345
1346 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1347 let title = self.record_failed_write(self.clean_title(text))?;
1348 if title.trim().is_empty() {
1349 return self.record_failed_write(Err(spar_err!(
1350 "nothing left of the title after cleaning it"
1351 )));
1352 }
1353 Ok(title)
1354 }
1355
1356 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1357 if self.followups == Followups::Issues {
1358 self.clean_nonempty_title_for_write(text)
1359 } else {
1360 self.clean_title(text)
1361 }
1362 }
1363
1364 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1367 ExecOpts::new()
1368 .cwd(cwd.unwrap_or(&self.root))
1369 .check(check)
1370 .timeout_secs(600)
1371 }
1372
1373 pub fn git(&self, args: &[&str]) -> Result<String> {
1374 self.git_at(None, args)
1375 }
1376
1377 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1378 let argv = git_without_maintenance_argv(args);
1379 proc::run(&argv, &self.git_opts(cwd, true))
1380 }
1381
1382 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1388 let argv = git_without_automation_argv(args);
1389 proc::run(
1390 &argv,
1391 &self.git_opts(Some(cwd), true).stop_descendants(true),
1392 )
1393 }
1394
1395 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1396 let argv = git_without_automation_argv(args);
1397 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1398 .map(|output| output.ok())
1399 }
1400
1401 pub fn git_try(&self, args: &[&str]) -> String {
1403 self.git_try_at(None, args)
1404 }
1405
1406 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1407 let argv = git_without_maintenance_argv(args);
1408 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1409 }
1410
1411 pub fn default_branch(&self, configured: &str) -> String {
1414 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1415 match refname.trim().rsplit('/').next() {
1416 Some(name) if !name.is_empty() => name.to_string(),
1417 _ => configured.to_string(),
1418 }
1419 }
1420
1421 pub fn branch_for_issue(&self, issue: i64) -> String {
1429 format!("{}issue-{issue}", self.branch_prefix)
1430 }
1431
1432 pub fn branch_for_pr(&self, number: i64) -> String {
1433 format!("{}pr-{number}", self.branch_prefix)
1434 }
1435
1436 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1445 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1446 }
1447
1448 fn ledger_path(&self) -> PathBuf {
1449 self.root.join(STATE_DIR).join("branches.json")
1450 }
1451
1452 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1453 std::fs::read_to_string(self.ledger_path())
1454 .ok()
1455 .and_then(|text| serde_json::from_str(&text).ok())
1456 .unwrap_or_default()
1457 }
1458
1459 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1460 let mut data = self.known_branches();
1461 data.insert(
1462 branch.to_string(),
1463 BranchRecord {
1464 kind: kind.to_string(),
1465 number,
1466 },
1467 );
1468 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1469 logdim!("could not record branch {branch}: {e}");
1470 }
1471 }
1472
1473 pub fn forget_branch(&self, branch: &str) {
1474 let mut data = self.known_branches();
1475 if data.remove(branch).is_none() {
1476 return;
1477 }
1478 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1479 logdim!("could not update the branch record: {e}");
1480 }
1481 }
1482
1483 fn worktree_path(&self, name: &str) -> PathBuf {
1486 self.root.join(WORKTREE_DIR).join(name)
1487 }
1488
1489 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1491 let branch = self.branch_for_issue(issue);
1492 let path = self.worktree_path(&format!("issue-{issue}"));
1493
1494 self.refuse_issue_branch_rebuild(issue, base)?;
1495 self.refuse_dirty_worktree(
1496 &path,
1497 &format!("worktree for issue #{issue}"),
1498 Disposable::BuildOutput,
1499 )?;
1500
1501 if !self.branch_deletion_is_safe(&branch)? {
1502 bail!(
1503 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1504 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1505 before retrying."
1506 );
1507 }
1508
1509 if !self.remove_worktree_at(&path)? {
1510 bail!(
1511 "the existing worktree for issue #{issue} could not be removed safely. Its \
1512 branch was kept."
1513 );
1514 }
1515 if !self.delete_branch_if_safe(&branch)? {
1516 bail!(
1517 "the existing branch {branch} changed or remained checked out while its \
1518 worktree was being rebuilt. It was kept."
1519 );
1520 }
1521
1522 if let Some(parent) = path.parent() {
1523 std::fs::create_dir_all(parent)
1524 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1525 }
1526
1527 let path_str = path.display().to_string();
1528 let remote_start = format!("origin/{base}");
1529 let created = self
1530 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1531 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1532
1533 created.map_err(|e| {
1536 spar_err!(
1537 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1538 and does `origin` exist?",
1539 e.last_line()
1540 )
1541 })?;
1542 self.record_branch(&branch, "issue", issue);
1543 Ok((path, branch))
1544 }
1545
1546 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1553 let branch = self.branch_for_issue(issue);
1554 let base_remote_ref = format!("refs/heads/{base}");
1555 let base_tracking_ref = format!("refs/remotes/origin/{base}");
1556 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1557 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1558 .map_err(|e| {
1559 spar_err!(
1560 "could not refresh origin/{base} before checking issue #{issue}: {}",
1561 e.last_line()
1562 )
1563 })?;
1564
1565 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1566 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1567 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1568 bail!(
1569 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1570 pull request accounts for them. Rebuilding it would force push over that \
1571 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1572 it, or delete it with `git push origin --delete {branch}` if the remote \
1573 branch is no longer needed."
1574 );
1575 }
1576 }
1577
1578 let local_ref = format!("refs/heads/{branch}");
1579 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1580 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1581 let recorded_pr = self
1582 .known_branches()
1583 .get(&branch)
1584 .is_some_and(|record| record.kind == "pr");
1585 let preserved = ahead == 0
1586 || if recorded_pr {
1587 self.local_branch_is_preserved(&branch)?
1588 } else {
1589 self.pull_request_holds(&branch, &local_ref, base)
1590 };
1591 if !preserved {
1592 let listed = self
1593 .commit_lines(&self.root, &local_ref, base)
1594 .iter()
1595 .map(|line| format!(" {line}"))
1596 .collect::<Vec<_>>()
1597 .join("\n");
1598 bail!(
1599 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1600 no pull request preserves them. Rebuilding it would delete the only copy.\n\
1601 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1602 it, or delete it with `git branch -D {branch}` if it is stale."
1603 );
1604 }
1605 }
1606 Ok(())
1607 }
1608
1609 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1610 let live_ref = format!("refs/heads/{branch}");
1611 let tracking_ref = format!("refs/remotes/origin/{branch}");
1612 let listed = self
1613 .git(&["ls-remote", "--heads", "origin", &live_ref])
1614 .map_err(|e| {
1615 spar_err!(
1616 "could not verify whether origin/{branch} still exists: {}",
1617 e.last_line()
1618 )
1619 })?;
1620
1621 if remote_head_oid(&listed, &live_ref)?.is_some() {
1622 let refspec = format!("+{live_ref}:{tracking_ref}");
1623 self.git(&["fetch", "--no-tags", "origin", &refspec])
1624 .map_err(|e| {
1625 spar_err!(
1626 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1627 e.last_line()
1628 )
1629 })?;
1630 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1631 bail!("origin/{branch} was fetched but its tracking ref is missing");
1632 }
1633 return Ok(Some(tracking_ref));
1634 }
1635
1636 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1637 return Ok(None);
1638 }
1639 let expected = self
1640 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1641 .trim()
1642 .to_string();
1643 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1644 .map_err(|e| {
1645 spar_err!(
1646 "could not discard stale origin/{branch} tracking ref safely: {}",
1647 e.last_line()
1648 )
1649 })?;
1650 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1651 bail!(
1652 "origin/{branch} changed while its stale tracking ref was being removed. It was \
1653 kept."
1654 );
1655 }
1656 Ok(None)
1657 }
1658
1659 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1668 self.prs_for_branch(branch)
1669 .iter()
1670 .any(|pr| self.pr_head_holds(pr.number, refname, base))
1671 }
1672
1673 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1674 let head = format!("refs/spar/pr-head/{number}");
1675 let refspec = format!("+refs/pull/{number}/head:{head}");
1676 if self.git(&["fetch", "origin", &refspec]).is_err() {
1677 return false;
1678 }
1679 let held = self.commits_held_by(refname, base, &head);
1680 self.git_try(&["update-ref", "-d", &head]);
1681 held
1682 }
1683
1684 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1685 let argv = vec![
1686 "git".to_string(),
1687 "merge-base".to_string(),
1688 "--is-ancestor".to_string(),
1689 older.to_string(),
1690 newer.to_string(),
1691 ];
1692 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1693 match out.code {
1694 0 => Ok(true),
1695 1 => Ok(false),
1696 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1697 }
1698 }
1699
1700 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1701 let head = format!("refs/spar/pr-head/{number}");
1702 let refspec = format!("+refs/pull/{number}/head:{head}");
1703 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1704 spar_err!(
1705 "could not verify the immutable head of PR #{number}: {}",
1706 e.last_line()
1707 )
1708 })?;
1709 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1710 self.git_try(&["update-ref", "-d", &head]);
1711 held
1712 }
1713
1714 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1715 let text = self.gh(&[
1716 "pr",
1717 "list",
1718 "--head",
1719 branch,
1720 "--state",
1721 "all",
1722 "--json",
1723 "number,url,title",
1724 ])?;
1725 serde_json::from_str(text.trim())
1726 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1727 }
1728
1729 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1730 let branch_ref = format!("refs/heads/{branch}");
1731 if record.kind == "pr" {
1732 return self.pr_head_contains_checked(record.number, &branch_ref);
1733 }
1734 let prs = self.branch_prs_checked(branch)?;
1735 if prs.is_empty() {
1736 return Ok(false);
1737 }
1738 for pr in prs {
1739 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1740 return Ok(true);
1741 }
1742 }
1743 Ok(false)
1744 }
1745
1746 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1747 let local_ref = format!("refs/heads/{branch}");
1748 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1749 return Ok(true);
1750 }
1751 let oid = self
1752 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1753 .trim()
1754 .to_string();
1755 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
1756 if !durable_tip {
1757 let remote_ref = format!("refs/heads/{branch}");
1758 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
1759 durable_tip = remote.lines().any(|line| {
1760 line.split_whitespace()
1761 .next()
1762 .is_some_and(|remote_oid| remote_oid == oid)
1763 });
1764 }
1765 if !durable_tip {
1766 if let Some(record) = self.known_branches().get(branch) {
1767 durable_tip = self.branch_is_preserved_checked(branch, record)?;
1768 }
1769 }
1770 if !durable_tip {
1771 return Ok(false);
1772 }
1773 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1774 }
1775
1776 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
1780 let local_ref = format!("refs/heads/{branch}");
1781 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1782 return Ok(true);
1783 }
1784 let expected = self
1785 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1786 .trim()
1787 .to_string();
1788 if !self.branch_deletion_is_safe(branch)? {
1789 return Ok(false);
1790 }
1791 let checked_out = self
1792 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
1793 .lines()
1794 .any(|line| line == format!("branch {local_ref}"));
1795 if checked_out {
1796 return Ok(false);
1797 }
1798 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1799 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1800 }
1801
1802 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
1803 let local_ref = review_ref(number);
1804 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1805 return Ok(true);
1806 }
1807 let oid = self
1808 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1809 .trim()
1810 .to_string();
1811 if !self.pr_head_contains_checked(number, &local_ref)? {
1812 return Ok(false);
1813 }
1814 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1815 }
1816
1817 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
1818 let local_ref = review_ref(number);
1819 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1820 return Ok(true);
1821 }
1822 let expected = self
1823 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1824 .trim()
1825 .to_string();
1826 if !self.review_ref_deletion_is_safe(number)? {
1827 return Ok(false);
1828 }
1829 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1830 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1831 }
1832
1833 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
1837 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
1838 self.git_try(&["rev-list", "--count", &range, "--not", other])
1839 .trim()
1840 == "0"
1841 }
1842
1843 pub fn worktree_remove(&self, issue: i64) -> bool {
1844 let path = self.worktree_path(&format!("issue-{issue}"));
1845 match self.remove_worktree_at(&path) {
1846 Ok(removed) => removed,
1847 Err(error) => {
1848 logdim!(
1849 "kept {} because removal did not reach a confirmed quiet point: {}",
1850 path.display(),
1851 error.last_line()
1852 );
1853 false
1854 }
1855 }
1856 }
1857
1858 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
1863 let wanted = std::fs::canonicalize(path)
1864 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
1865 if wanted != path {
1870 return Ok(false);
1871 }
1872 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
1873 let raw = PathBuf::from(value.trim());
1874 let joined = if raw.is_absolute() {
1875 raw
1876 } else {
1877 cwd.join(raw)
1878 };
1879 std::fs::canonicalize(&joined)
1880 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
1881 };
1882 let expected =
1883 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
1884 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
1885 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
1886 let expected = resolve(&self.root, &expected)?;
1887 let actual = resolve(path, &actual)?;
1888 let top = resolve(path, &top)?;
1889 Ok(expected == actual && top == wanted)
1890 }
1891
1892 fn remove_worktree_at_with_force(
1898 &self,
1899 path: &Path,
1900 force: bool,
1901 disposable: Disposable,
1902 ) -> Result<bool> {
1903 let existed = path.exists();
1904 if path.exists() {
1905 match self.worktree_belongs_to_repo(path) {
1906 Ok(true) => {}
1907 Ok(false) => {
1908 logdim!(
1909 "kept {} because it is not a worktree owned by this repository",
1910 path.display()
1911 );
1912 return Ok(false);
1913 }
1914 Err(e) => {
1915 logdim!(
1916 "kept {} because its worktree ownership could not be verified: {}",
1917 path.display(),
1918 e.last_line()
1919 );
1920 return Ok(false);
1921 }
1922 }
1923 if !force {
1924 match self.has_recoverable_work(path, disposable) {
1925 Ok(true) => {
1926 logdim!(
1927 "kept {} because it contains recoverable files or repository state",
1928 path.display()
1929 );
1930 return Ok(false);
1931 }
1932 Err(e) => {
1933 logdim!(
1934 "kept {} because its recoverable state could not be checked: {}",
1935 path.display(),
1936 e.last_line()
1937 );
1938 return Ok(false);
1939 }
1940 Ok(false) => {}
1941 }
1942 }
1943 }
1944 let path_str = path.display().to_string();
1950 let command_ok = if force || disposable == Disposable::ReviewOutput {
1951 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
1952 } else {
1953 self.git_try_without_automation(&["worktree", "remove", &path_str])?
1954 };
1955 Ok((command_ok || !existed) && !path.exists())
1956 }
1957
1958 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
1959 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
1960 }
1961
1962 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
1964 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
1965 }
1966
1967 fn remove_worktree_at_force(&self, path: &Path) -> bool {
1969 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
1970 Ok(removed) => removed,
1971 Err(error) => {
1972 logdim!(
1973 "kept {} because removal did not reach a confirmed quiet point: {}",
1974 path.display(),
1975 error.last_line()
1976 );
1977 false
1978 }
1979 }
1980 }
1981
1982 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
1987 if path.exists() && !self.worktree_belongs_to_repo(path)? {
1988 bail!(
1989 "{} is not a worktree owned by this repository, so it was kept",
1990 path.display()
1991 );
1992 }
1993 if path.exists() && self.has_recoverable_work(path, disposable)? {
1994 bail!(
1995 "the verified worktree at {} contains recoverable files or repository state. It \
1996 was kept.",
1997 path.display()
1998 );
1999 }
2000 let path_str = path.display().to_string();
2001 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2002 .map_err(|e| {
2003 e.with_message(format!(
2004 "could not remove the verified worktree at {}: {}. It was kept.",
2005 path.display(),
2006 e.last_line()
2007 ))
2008 })?;
2009 Ok(!path.exists())
2010 }
2011
2012 fn refuse_dirty_worktree(
2013 &self,
2014 path: &Path,
2015 label: &str,
2016 disposable: Disposable,
2017 ) -> Result<()> {
2018 if !path.is_dir() {
2019 return Ok(());
2020 }
2021 let has_files = std::fs::read_dir(path)
2022 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2023 .next()
2024 .is_some();
2025 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2026 spar_err!(
2027 "could not verify whether the existing {label} at {} belongs to this repository, \
2028 so it was kept: {}",
2029 path.display(),
2030 e.last_line()
2031 )
2032 })?;
2033 if !owned {
2034 if has_files {
2035 bail!(
2036 "the existing {label} at {} is not a worktree owned by \
2037 this repository. Refusing to remove it.",
2038 path.display()
2039 );
2040 }
2041 return Ok(());
2042 }
2043 if !path.join(".git").exists() {
2044 if has_files {
2045 bail!(
2046 "the existing {label} at {} is not a readable Git worktree and is not empty. \
2047 Refusing to remove it.",
2048 path.display()
2049 );
2050 }
2051 return Ok(());
2052 }
2053 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2054 spar_err!(
2055 "could not verify whether the existing {label} at {} is clean, so it was kept: \
2056 {}",
2057 path.display(),
2058 e.last_line()
2059 )
2060 })?;
2061 if dirty {
2062 bail!(
2063 "the existing {label} contains uncommitted changes or ignored files at {}. \
2064 Rebuilding it would delete those files.\nCommit or recover them before running this \
2065 command again, or use `spar clean --all` if they are not needed.",
2066 path.display()
2067 );
2068 }
2069 Ok(())
2070 }
2071
2072 pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
2074 let head = pr.head_ref_name.clone();
2075 if head.trim().is_empty() {
2076 bail!("PR #{} has no head branch to check out", pr.number);
2077 }
2078 let path = self.worktree_path(&format!("pr-{}", pr.number));
2079 let local = self.branch_for_pr(pr.number);
2080
2081 self.git(&["fetch", "origin", &head]).map_err(|e| {
2082 spar_err!(
2083 "could not fetch the branch behind PR #{}: {}",
2084 pr.number,
2085 e.last_line()
2086 )
2087 })?;
2088 let start = format!("origin/{head}");
2089 let start_ref = format!("refs/remotes/origin/{head}");
2090 let local_ref = format!("refs/heads/{local}");
2091 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2092 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2093 if unpushed > 0 {
2094 bail!(
2095 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2096 on {start}. Rebuilding it would delete their branch.\nInspect the worktree at \
2097 {} and push or recover those commits before running this command again.",
2098 pr.number,
2099 path.display()
2100 );
2101 }
2102 }
2103 self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2104 self.refuse_dirty_worktree(
2105 &path,
2106 &format!("worktree for PR #{}", pr.number),
2107 Disposable::BuildOutput,
2108 )?;
2109 if !self.branch_deletion_is_safe(&local)? {
2110 bail!(
2111 "the existing branch {local} has a tip or reflog-only commit that no surviving \
2112 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2113 before retrying."
2114 );
2115 }
2116 if !self.remove_worktree_at(&path)? {
2117 bail!(
2118 "the existing worktree for PR #{} could not be removed safely. Its branch was \
2119 kept.",
2120 pr.number
2121 );
2122 }
2123 if !self.delete_branch_if_safe(&local)? {
2124 bail!(
2125 "the existing branch {local} changed or remained checked out while the PR \
2126 worktree was being rebuilt. It was kept."
2127 );
2128 }
2129
2130 let path_str = path.display().to_string();
2131 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2132 self.record_branch(&local, "pr", pr.number);
2133 Ok((path, head))
2134 }
2135
2136 fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2139 if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2140 return Ok(());
2141 }
2142 let current =
2143 self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2144 if current.trim() != branch
2145 || self.has_uncommitted_changes(path)?
2146 || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2147 {
2148 return Ok(());
2149 }
2150 static NEXT: AtomicUsize = AtomicUsize::new(0);
2151 let (name, retained) = loop {
2152 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2153 let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2154 let retained = self.worktree_path(&name);
2155 if !retained
2156 .try_exists()
2157 .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2158 && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2159 {
2160 break (name, retained);
2161 }
2162 };
2163 self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2166 if let Err(error) = self.git_at_without_automation(
2167 &self.root,
2168 &[
2169 "worktree",
2170 "move",
2171 &path.display().to_string(),
2172 &retained.display().to_string(),
2173 ],
2174 ) {
2175 let rollback =
2178 self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2179 let detail = rollback
2180 .err()
2181 .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2182 .unwrap_or_default();
2183 bail!(
2184 "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2185 retained.display(),
2186 error.last_line()
2187 );
2188 }
2189 logdim!(
2190 "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2191 retained.display()
2192 );
2193 Ok(())
2194 }
2195
2196 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2206 let path = self.worktree_path(&format!("review-{number}"));
2207 let local_ref = review_ref(number);
2208 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2209
2210 self.refuse_review_worktree_changes(number)?;
2211
2212 if let Some(parent) = path.parent() {
2213 std::fs::create_dir_all(parent)
2214 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2215 }
2216 if !self.remove_review_worktree_at(&path)? {
2221 bail!(
2222 "the existing review worktree for PR #{number} could not be removed safely. Its \
2223 reference was kept."
2224 );
2225 }
2226
2227 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2228 spar_err!(
2229 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2230 every pull request, so this usually means the number is wrong or `origin` does \
2231 not point at the repository the PR is on.",
2232 e.last_line()
2233 )
2234 })?;
2235
2236 let path_str = path.display().to_string();
2237 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2238 Ok(path)
2239 }
2240
2241 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2242 let path = self.worktree_path(&format!("review-{number}"));
2243 if !path.is_dir() {
2244 return Ok(());
2245 }
2246 let local_ref = review_ref(number);
2247 if !self.worktree_belongs_to_repo(&path)? {
2248 return Ok(());
2249 }
2250 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2251 bail!(
2252 "the existing review worktree for PR #{number} has no recorded head at \
2253 {local_ref}. Refusing to rebuild {}.",
2254 path.display()
2255 );
2256 }
2257 let worktree_head = self.head_oid_checked(&path)?;
2258 let recorded_head = self
2259 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2260 .trim()
2261 .to_string();
2262 if worktree_head != recorded_head {
2263 bail!(
2264 "the existing review worktree for PR #{number} has a local commit that is not on \
2265 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2266 {} before retrying.",
2267 path.display()
2268 );
2269 }
2270 self.report_recovery_markers(&path);
2271 self.refuse_dirty_worktree(
2272 &path,
2273 &format!("review worktree for PR #{number}"),
2274 Disposable::ReviewOutput,
2275 )?;
2276 Ok(())
2277 }
2278
2279 fn report_recovery_markers(&self, path: &Path) {
2287 let Ok(entries) = std::fs::read_dir(path) else {
2288 return;
2289 };
2290 for entry in entries.flatten() {
2291 let name = entry.file_name();
2292 let Some(name) = name.to_str() else { continue };
2293 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2294 continue;
2295 }
2296 match std::fs::read_to_string(entry.path()) {
2297 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2298 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2299 }
2300 }
2301 }
2302
2303 pub fn worktree_for_split(
2316 &self,
2317 parent: i64,
2318 index: usize,
2319 start: &str,
2320 ) -> Result<(PathBuf, String)> {
2321 let slot = self.free_split_slot(parent, index)?;
2322 let branch = format!("{}{slot}", self.branch_prefix);
2323 let path = self.worktree_path(&slot);
2324
2325 if let Some(dir) = path.parent() {
2326 std::fs::create_dir_all(dir)
2327 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2328 }
2329 self.refuse_dirty_worktree(
2330 &path,
2331 &format!("worktree for part {index} of PR #{parent}"),
2332 Disposable::BuildOutput,
2333 )?;
2334 if !self.remove_worktree_at(&path)? {
2338 bail!(
2339 "the existing worktree for part {index} of PR #{parent} could not be removed \
2340 safely. No branch was created."
2341 );
2342 }
2343
2344 let path_str = path.display().to_string();
2345 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2346 .map_err(|e| {
2347 spar_err!(
2348 "could not create a worktree for part {index} of #{parent}. {}",
2349 e.last_line()
2350 )
2351 })?;
2352 self.record_branch(&branch, "split", parent);
2355 Ok((path, branch))
2356 }
2357
2358 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2365 for attempt in 1..=SPLIT_SLOTS {
2366 let slot = split_slot(parent, index, attempt);
2367 let branch = format!("{}{slot}", self.branch_prefix);
2368 self.git_try(&["fetch", "origin", &branch]);
2369 if !self.rev_exists(&self.root, &branch)
2370 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2371 {
2372 return Ok(slot);
2373 }
2374 }
2375 bail!(
2376 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2377 names are all taken. Inspect the existing branches and child pull requests. Finish \
2378 recording the earlier split, or remove every retained local worktree and branch, \
2379 child pull request, and remote split branch before starting over.",
2380 self.branch_for_split(parent, index)
2381 )
2382 }
2383
2384 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2390 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2391 Ok(!self
2392 .git(&["ls-remote", "--heads", "origin", &pattern])?
2393 .trim()
2394 .is_empty())
2395 }
2396
2397 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2404 match self.branch_deletion_is_safe(branch) {
2405 Ok(true) => {}
2406 Ok(false) => {
2407 logdim!(
2408 "kept {branch} and {} because no surviving ref preserves its tip",
2409 dir.display()
2410 );
2411 return;
2412 }
2413 Err(error) => {
2414 logdim!(
2415 "kept {branch} and {} because preservation could not be verified: {}",
2416 dir.display(),
2417 error.last_line()
2418 );
2419 return;
2420 }
2421 }
2422 match self.remove_worktree_at(dir) {
2423 Ok(true) => match self.delete_branch_if_safe(branch) {
2424 Ok(true) => self.forget_branch(branch),
2425 Ok(false) => {
2426 logdim!("kept {branch} because its tip or reflog changed before deletion")
2427 }
2428 Err(error) => logdim!(
2429 "kept {branch} because deletion safety could not be rechecked: {}",
2430 error.last_line()
2431 ),
2432 },
2433 Ok(false) => {}
2434 Err(error) => logdim!(
2435 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2436 dir.display(),
2437 error.last_line()
2438 ),
2439 }
2440 }
2441
2442 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2448 let record = self.known_branches().get(branch).cloned();
2449 if record.is_none_or(|record| record.kind != "split") {
2450 logdim!("kept {branch} because no split branch record proves ownership");
2451 return false;
2452 }
2453 let local_ref = format!("refs/heads/{branch}");
2454 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2455 Ok(value) => value.trim().to_string(),
2456 Err(error) => {
2457 logdim!(
2458 "kept {branch} because its tip could not be checked: {}",
2459 error.last_line()
2460 );
2461 return false;
2462 }
2463 };
2464 if expected != disposable_head {
2465 logdim!("kept {branch} because it moved beyond the disposable slice");
2466 return false;
2467 }
2468 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2469 Ok(true) => {}
2470 Ok(false) => {
2471 logdim!(
2472 "kept {branch} because its reflog contains work outside the disposable slice"
2473 );
2474 return false;
2475 }
2476 Err(error) => {
2477 logdim!(
2478 "kept {branch} because its reflog could not be checked: {}",
2479 error.last_line()
2480 );
2481 return false;
2482 }
2483 }
2484 match self.head_oid_checked(dir) {
2485 Ok(head) if head == disposable_head => {}
2486 Ok(_) => {
2487 logdim!(
2488 "kept {branch} and {} because the worktree moved beyond the disposable slice",
2489 dir.display()
2490 );
2491 return false;
2492 }
2493 Err(error) => {
2494 logdim!(
2495 "kept {branch} and {} because its head could not be checked: {}",
2496 dir.display(),
2497 error.last_line()
2498 );
2499 return false;
2500 }
2501 }
2502 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2503 Ok(true) => {}
2504 Ok(false) => return false,
2505 Err(error) => {
2506 logdim!(
2507 "kept {branch} and {} because the disposable slice could not be verified: {}",
2508 dir.display(),
2509 error.last_line()
2510 );
2511 return false;
2512 }
2513 }
2514 if let Err(error) =
2515 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2516 {
2517 logdim!(
2518 "kept {branch} because its exact disposable tip could not be deleted: {}",
2519 error.last_line()
2520 );
2521 return false;
2522 }
2523 match self.exact_ref_exists_checked(&self.root, &local_ref) {
2524 Ok(false) => {
2525 self.forget_branch(branch);
2526 true
2527 }
2528 Ok(true) => {
2529 logdim!("kept {branch} because its ref still exists after deletion");
2530 false
2531 }
2532 Err(error) => {
2533 logdim!(
2534 "kept the branch record for {branch} because deletion could not be verified: {}",
2535 error.last_line()
2536 );
2537 false
2538 }
2539 }
2540 }
2541
2542 pub fn release_review_worktree(&self, number: i64) {
2543 let path = self.worktree_path(&format!("review-{number}"));
2544 match self.review_ref_deletion_is_safe(number) {
2545 Ok(true) => {}
2546 Ok(false) => {
2547 logdim!(
2548 "kept {} because no surviving ref preserves its review history",
2549 path.display()
2550 );
2551 return;
2552 }
2553 Err(error) => {
2554 logdim!(
2555 "kept {} because review history could not be verified: {}",
2556 path.display(),
2557 error.last_line()
2558 );
2559 return;
2560 }
2561 }
2562 match self.remove_worktree_at(&path) {
2563 Ok(true) => match self.delete_review_ref_if_safe(number) {
2564 Ok(true) => {}
2565 Ok(false) => logdim!(
2566 "kept {} because its review history changed before deletion",
2567 review_ref(number)
2568 ),
2569 Err(error) => logdim!(
2570 "kept {} because deletion safety could not be rechecked: {}",
2571 review_ref(number),
2572 error.last_line()
2573 ),
2574 },
2575 Ok(false) => {}
2576 Err(error) => logdim!(
2577 "kept {} because removal did not reach a confirmed quiet point: {}",
2578 path.display(),
2579 error.last_line()
2580 ),
2581 }
2582 }
2583
2584 pub(crate) fn release_review_worktree_checked(
2587 &self,
2588 number: i64,
2589 checkpoint: &WorktreeCheckpoint,
2590 ) -> Result<()> {
2591 let path = self.worktree_path(&format!("review-{number}"));
2592 self.require_unchanged_worktree(
2593 &path,
2594 checkpoint,
2595 &format!("review worktree for PR #{number}"),
2596 )?;
2597 if !self.review_ref_deletion_is_safe(number)? {
2598 bail!(
2599 "the review reference for PR #{number} has reflog-only recovery history. The \
2600 worktree and reference were kept."
2601 );
2602 }
2603 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2604 bail!(
2605 "the verified review worktree at {} could not be removed, so its reference was \
2606 kept",
2607 path.display()
2608 );
2609 }
2610 if !self.delete_review_ref_if_safe(number)? {
2611 bail!(
2612 "the review reference for PR #{number} changed before deletion. The reference was \
2613 kept."
2614 );
2615 }
2616 Ok(())
2617 }
2618
2619 pub fn release_pr_worktree(&self, number: i64) -> bool {
2620 let path = self.worktree_path(&format!("pr-{number}"));
2621 let local = self.branch_for_pr(number);
2622 match self.branch_deletion_is_safe(&local) {
2623 Ok(true) => {}
2624 Ok(false) => {
2625 logdim!(
2626 "kept {local} and {} because no surviving ref preserves its tip",
2627 path.display()
2628 );
2629 return false;
2630 }
2631 Err(error) => {
2632 logdim!(
2633 "kept {local} and {} because preservation could not be verified: {}",
2634 path.display(),
2635 error.last_line()
2636 );
2637 return false;
2638 }
2639 }
2640 match self.remove_worktree_at(&path) {
2641 Ok(true) => match self.delete_branch_if_safe(&local) {
2642 Ok(true) => {
2643 self.forget_branch(&local);
2644 true
2645 }
2646 Ok(false) => {
2647 logdim!("kept {local} because its tip or reflog changed before deletion");
2648 false
2649 }
2650 Err(error) => {
2651 logdim!(
2652 "kept {local} because deletion safety could not be rechecked: {}",
2653 error.last_line()
2654 );
2655 false
2656 }
2657 },
2658 Ok(false) => false,
2659 Err(error) => {
2660 logdim!(
2661 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
2662 path.display(),
2663 error.last_line()
2664 );
2665 false
2666 }
2667 }
2668 }
2669
2670 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
2681 let remote = format!("origin/{base}");
2682 if self.rev_exists(cwd, &remote) {
2683 return remote;
2684 }
2685 if self.rev_exists(cwd, base) {
2686 logdim!("origin/{base} does not resolve, comparing against local {base}");
2687 return base.to_string();
2688 }
2689 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
2690 remote
2691 }
2692
2693 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
2694 let spec = format!("{refname}^{{commit}}");
2695 !self
2696 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
2697 .trim()
2698 .is_empty()
2699 }
2700
2701 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2702 let range = format!("{}..HEAD", self.base_ref(cwd, base));
2703 !self
2704 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2705 .trim()
2706 .is_empty()
2707 }
2708
2709 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2710 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2711 Ok(found.lines().any(|line| line.trim() == refname))
2712 }
2713
2714 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2715 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2716 count.trim().parse::<usize>().map_err(|e| {
2717 spar_err!(
2718 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2719 count.trim()
2720 )
2721 })
2722 }
2723
2724 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2725 let remote = format!("refs/remotes/origin/{base}");
2726 if self.exact_ref_exists_checked(cwd, &remote)? {
2727 return Ok(remote);
2728 }
2729 let local = format!("refs/heads/{base}");
2730 if self.exact_ref_exists_checked(cwd, &local)? {
2731 return Ok(local);
2732 }
2733 bail!("neither origin/{base} nor local branch {base} resolves")
2734 }
2735
2736 pub(crate) fn commit_count_checked(
2737 &self,
2738 cwd: &Path,
2739 refname: &str,
2740 base: &str,
2741 ) -> Result<usize> {
2742 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
2743 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
2744 count.trim().parse::<usize>().map_err(|e| {
2745 spar_err!(
2746 "git returned an invalid commit count for {range}: {:?} ({e})",
2747 count.trim()
2748 )
2749 })
2750 }
2751
2752 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
2753 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
2754 }
2755
2756 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
2757 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
2758 let head = head.trim().to_string();
2759 if head.is_empty() {
2760 bail!("git returned an empty HEAD for {}", cwd.display());
2761 }
2762 Ok(head)
2763 }
2764
2765 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
2770 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
2771 self.local_branch_is_preserved(branch.trim())
2772 }
2773
2774 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
2777 let known = self.known_branches();
2778 let Some(record) = known.get(branch) else {
2779 return Ok(false);
2780 };
2781 self.branch_is_preserved_checked(branch, record)
2782 }
2783
2784 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
2786 has_uncommitted_work(cwd)
2787 }
2788
2789 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
2792 repository_has_recoverable_work_with(cwd, true, disposable)
2793 }
2794
2795 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2797 let attributes = attribute_state(cwd)?;
2798 Ok(WorktreeBaseline {
2799 attributes,
2800 ignored_untracked: ignored_untracked_state(cwd)?,
2801 git_state: safe_git_state(cwd)?,
2802 })
2803 }
2804
2805 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2808 let attributes = attribute_state(cwd)?;
2809 Ok(WorktreeCheckpoint {
2810 path: std::fs::canonicalize(cwd)
2811 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2812 attributes,
2813 git_state: safe_git_state(cwd)?,
2814 ignored_untracked: ignored_untracked_state(cwd)?,
2815 })
2816 }
2817
2818 pub(crate) fn require_unchanged_worktree(
2821 &self,
2822 cwd: &Path,
2823 checkpoint: &WorktreeCheckpoint,
2824 label: &str,
2825 ) -> Result<()> {
2826 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2827 crate::error::SparError::uncertain_write(format!(
2828 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2829 cwd.display()
2830 ))
2831 })?;
2832 if resolved != checkpoint.path {
2833 return Err(uncertain_worktree_change(
2834 cwd,
2835 format!(
2836 "the {label} moved from {} to {} during inspection. It was kept.",
2837 checkpoint.path.display(),
2838 resolved.display()
2839 ),
2840 ));
2841 }
2842 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
2843 uncertain_worktree_change(
2844 cwd,
2845 format!(
2846 "could not verify attribute files in the {label} at {}: {}. It was kept.",
2847 cwd.display(),
2848 e.last_line()
2849 ),
2850 )
2851 })?;
2852 if attributes != checkpoint.attributes {
2853 return Err(uncertain_worktree_change(
2854 cwd,
2855 format!(
2856 "attribute files in the {label} at {} changed during inspection. It was \
2857 kept for recovery.",
2858 cwd.display()
2859 ),
2860 ));
2861 }
2862 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
2863 uncertain_worktree_change(
2864 cwd,
2865 format!(
2866 "could not verify the Git state of the {label} at {}: {}. It was kept.",
2867 cwd.display(),
2868 e.last_line()
2869 ),
2870 )
2871 })?;
2872 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
2873 uncertain_worktree_change(
2874 cwd,
2875 format!(
2876 "could not verify untracked files in the {label} at {}: {}. It was kept.",
2877 cwd.display(),
2878 e.last_line()
2879 ),
2880 )
2881 })?;
2882 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
2883 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
2887 let tracked = checkpoint.git_state.describe_difference(&git_state);
2888 let untracked = (!leavings.condemning.is_empty()).then(|| {
2889 format!(
2890 "untracked file(s) changed: {}",
2891 list_paths(&leavings.condemning)
2892 )
2893 });
2894 let Some(reason) = join_reasons(tracked, untracked) else {
2895 return Ok(());
2896 };
2897 Err(uncertain_worktree_change(
2898 cwd,
2899 format!(
2900 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
2901 for recovery.",
2902 cwd.display()
2903 ),
2904 ))
2905 }
2906
2907 pub(crate) fn refuse_new_ignored_files(
2911 &self,
2912 cwd: &Path,
2913 baseline: &WorktreeBaseline,
2914 ) -> Result<()> {
2915 self.allow_ignored_files(cwd, baseline).map(drop)
2916 }
2917
2918 pub(crate) fn refuse_failed_edit_leavings(
2920 &self,
2921 cwd: &Path,
2922 baseline: &WorktreeBaseline,
2923 ) -> Result<()> {
2924 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
2925 .map(drop)
2926 }
2927
2928 pub(crate) fn refuse_read_only_leavings(
2930 &self,
2931 cwd: &Path,
2932 baseline: &WorktreeBaseline,
2933 ) -> Result<()> {
2934 self.refuse_changed_attributes(cwd, baseline)?;
2935 let after = ignored_untracked_state(cwd).map_err(|e| {
2936 uncertain_worktree_change(
2937 cwd,
2938 format!(
2939 "could not verify untracked files in {} after a read-only call: {}. The \
2940 worktree was kept for recovery.",
2941 cwd.display(),
2942 e.last_line()
2943 ),
2944 )
2945 })?;
2946 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
2947 if leavings.condemning.is_empty() {
2948 return Ok(());
2949 }
2950 Err(uncertain_worktree_change(
2951 cwd,
2952 format!(
2953 "the read-only call left untracked file(s) in {} that the project does not \
2954 ignore: {}. The worktree was kept for recovery.",
2955 cwd.display(),
2956 list_paths(&leavings.condemning)
2957 ),
2958 ))
2959 }
2960
2961 fn allow_ignored_files(
2964 &self,
2965 cwd: &Path,
2966 baseline: &WorktreeBaseline,
2967 ) -> Result<AllowedArtifacts> {
2968 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
2969 }
2970
2971 fn check_new_ignored_files(
2972 &self,
2973 cwd: &Path,
2974 baseline: &WorktreeBaseline,
2975 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
2976 ) -> Result<AllowedArtifacts> {
2977 self.refuse_changed_attributes(cwd, baseline)?;
2978 let after = ignored_untracked_state(cwd).map_err(|e| {
2979 uncertain_worktree_change(
2980 cwd,
2981 format!(
2982 "could not verify untracked files in {} after editing: {}. The worktree was \
2983 kept for recovery.",
2984 cwd.display(),
2985 e.last_line()
2986 ),
2987 )
2988 })?;
2989 let changed = baseline.ignored_untracked.changed_paths(&after);
2990 if changed.is_empty() {
2991 return Ok(AllowedArtifacts::default());
2992 }
2993 let (ignored, changed): (Vec<_>, Vec<_>) = changed
2994 .into_iter()
2995 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
2996 if changed.is_empty() {
2997 return Ok(AllowedArtifacts::split(ignored, &after));
2998 }
2999 let listed = list_paths(&changed);
3000 Err(uncertain_worktree_change(
3001 cwd,
3002 format!(
3003 "the editing call created or changed untracked or ignored file(s) in {} that \
3004 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3005 recovery.",
3006 cwd.display()
3007 ),
3008 ))
3009 }
3010
3011 pub(crate) fn refuse_changed_existing_untracked(
3016 &self,
3017 cwd: &Path,
3018 baseline: &WorktreeBaseline,
3019 ) -> Result<()> {
3020 self.check_changed_existing_untracked(cwd, baseline)
3021 .map(drop)
3022 }
3023
3024 fn allow_changed_ignored_artifacts(
3025 &self,
3026 cwd: &Path,
3027 baseline: &WorktreeBaseline,
3028 ) -> Result<AllowedArtifacts> {
3029 self.check_changed_existing_untracked(cwd, baseline)
3030 }
3031
3032 fn check_changed_existing_untracked(
3033 &self,
3034 cwd: &Path,
3035 baseline: &WorktreeBaseline,
3036 ) -> Result<AllowedArtifacts> {
3037 self.refuse_changed_attributes(cwd, baseline)?;
3038 let after = ignored_untracked_state(cwd).map_err(|e| {
3039 uncertain_worktree_change(
3040 cwd,
3041 format!(
3042 "could not verify existing untracked files in {} after editing: {}. The \
3043 worktree was kept for recovery.",
3044 cwd.display(),
3045 e.last_line()
3046 ),
3047 )
3048 })?;
3049 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3050 if changed.is_empty() {
3051 return Ok(AllowedArtifacts::default());
3052 }
3053 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3054 .into_iter()
3055 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3056 if changed.is_empty() {
3057 return Ok(AllowedArtifacts::split(ignored, &after));
3058 }
3059 let listed = list_paths(&changed);
3060 Err(uncertain_worktree_change(
3061 cwd,
3062 format!(
3063 "the editing call changed or deleted existing untracked file(s) in {}: \
3064 {listed}. The worktree was kept for recovery.",
3065 cwd.display()
3066 ),
3067 ))
3068 }
3069
3070 pub(crate) fn refuse_unrepresented_tracked_changes(
3077 &self,
3078 cwd: &Path,
3079 baseline: &WorktreeBaseline,
3080 ) -> Result<()> {
3081 self.refuse_changed_attributes(cwd, baseline)?;
3082 let after = safe_git_state(cwd).map_err(|e| {
3083 uncertain_worktree_change(
3084 cwd,
3085 format!(
3086 "could not verify tracked files in {} after editing: {}. The worktree was \
3087 kept for recovery.",
3088 cwd.display(),
3089 e.last_line()
3090 ),
3091 )
3092 })?;
3093 let mut changed = Vec::new();
3094 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3095 uncertain_worktree_change(
3096 cwd,
3097 format!(
3098 "could not record untracked files before verifying transformed content in {}: \
3099 {}. The worktree was kept for recovery.",
3100 cwd.display(),
3101 e.last_line()
3102 ),
3103 )
3104 })?;
3105 let mut filter_was_run = false;
3106 let mut filter_problem = None;
3107 let mut repositories: BTreeSet<PathBuf> =
3108 baseline.git_state.repositories.keys().cloned().collect();
3109 repositories.extend(after.repositories.keys().cloned());
3110 'repositories: for repository_path in repositories {
3111 let before_repository = baseline.git_state.repositories.get(&repository_path);
3112 let after_repository = after.repositories.get(&repository_path);
3113 if before_repository.is_none() || after_repository.is_none() {
3114 changed.push(repository_path.clone());
3115 continue;
3116 }
3117 if before_repository.map(|repository| &repository.gitlinks)
3118 != after_repository.map(|repository| &repository.gitlinks)
3119 {
3120 changed.push(repository_path.join("<gitlinks>"));
3121 }
3122 let mut paths = BTreeSet::new();
3123 if let Some(repository) = before_repository {
3124 paths.extend(repository.tracked.keys().cloned());
3125 }
3126 if let Some(repository) = after_repository {
3127 paths.extend(repository.tracked.keys().cloned());
3128 }
3129 for path in paths {
3130 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3131 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3132 let worktree_changed =
3133 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3134 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3135 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3136 if !worktree_changed {
3137 continue;
3138 }
3139 if !index_changed {
3140 changed.push(repository_path.join(&path));
3141 continue;
3142 }
3143 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3144 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3145 let Some(current_entry) = current else {
3146 continue;
3147 };
3148 let Some(current_worktree) = current_worktree else {
3149 continue;
3150 };
3151 let content_changed =
3152 before_worktree.map(|file| file.content) != Some(current_worktree.content);
3153 let mode_changed = before_worktree.map(|file| file.mode.as_str())
3154 != Some(current_worktree.mode.as_str());
3155 let repository = cwd.join(&repository_path);
3156 let represented_content = if content_changed {
3157 filter_was_run = true;
3158 let result =
3159 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
3160 self.refuse_changed_attributes(cwd, baseline)?;
3161 match result {
3162 Ok(expected) => expected == current_worktree.content,
3163 Err(error) => {
3164 filter_problem = Some(format!(
3165 "could not verify transformed content for {:?}: {}",
3166 repository_path.join(&path),
3167 error.last_line()
3168 ));
3169 false
3170 }
3171 }
3172 } else {
3173 true
3174 };
3175 let represented_mode =
3176 !mode_changed || current_worktree.mode == current_entry.index_mode;
3177 if !represented_content || !represented_mode {
3178 changed.push(repository_path.join(&path));
3179 }
3180 if filter_problem.is_some() {
3181 break 'repositories;
3182 }
3183 }
3184 }
3185 if filter_was_run {
3186 self.refuse_changed_attributes(cwd, baseline)?;
3187 let verified = safe_git_state(cwd).map_err(|e| {
3188 uncertain_worktree_change(
3189 cwd,
3190 format!(
3191 "could not recheck tracked files after verifying transformed content in \
3192 {}: {}. The worktree was kept for recovery.",
3193 cwd.display(),
3194 e.last_line()
3195 ),
3196 )
3197 })?;
3198 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3199 uncertain_worktree_change(
3200 cwd,
3201 format!(
3202 "could not recheck untracked files after verifying transformed content \
3203 in {}: {}. The worktree was kept for recovery.",
3204 cwd.display(),
3205 e.last_line()
3206 ),
3207 )
3208 })?;
3209 if verified != after
3210 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3211 {
3212 return Err(uncertain_worktree_change(
3213 cwd,
3214 "a content filter changed the worktree while SPAR verified the managed \
3215 commit. The worktree was kept for recovery.",
3216 ));
3217 }
3218 self.refuse_changed_existing_untracked(cwd, baseline)?;
3219 }
3220 if let Some(problem) = filter_problem {
3221 return Err(uncertain_worktree_change(
3222 cwd,
3223 format!("{problem}. The worktree was kept for recovery."),
3224 ));
3225 }
3226 if changed.is_empty() {
3227 return Ok(());
3228 }
3229 let listed = list_paths(&changed);
3230 Err(uncertain_worktree_change(
3231 cwd,
3232 format!(
3233 "the editing call changed tracked working-file bytes, modes, repositories, or \
3234 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3235 recovery."
3236 ),
3237 ))
3238 }
3239
3240 pub(crate) fn refuse_changed_attributes(
3241 &self,
3242 cwd: &Path,
3243 baseline: &WorktreeBaseline,
3244 ) -> Result<()> {
3245 let after = attribute_state(cwd).map_err(|e| {
3246 uncertain_worktree_change(
3247 cwd,
3248 format!(
3249 "could not verify attribute files in {} after editing: {}. The worktree was \
3250 kept for recovery.",
3251 cwd.display(),
3252 e.last_line()
3253 ),
3254 )
3255 })?;
3256 if after == baseline.attributes {
3257 return Ok(());
3258 }
3259 Err(uncertain_worktree_change(
3260 cwd,
3261 format!(
3262 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3263 refused to run a Git operation that could select a new external filter.",
3264 cwd.display()
3265 ),
3266 ))
3267 }
3268
3269 pub(crate) fn commit_pending_changes(
3274 &self,
3275 cwd: &Path,
3276 baseline: &WorktreeBaseline,
3277 preferred_subject: &str,
3278 fallback_subject: &str,
3279 ) -> Result<bool> {
3280 let mut artifacts = IgnoredArtifacts::default();
3281 self.refuse_changed_attributes(cwd, baseline)?;
3282 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3283 refuse_unsafe_index_flags(cwd)?;
3284 if !self.has_uncommitted_changes(cwd)? {
3285 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3286 artifacts.report(cwd);
3287 return Ok(false);
3288 }
3289 self.stage_managed_changes(cwd, baseline).map_err(|e| {
3290 e.with_message(format!(
3291 "could not stage changes in {}: {}",
3292 cwd.display(),
3293 e.last_line()
3294 ))
3295 })?;
3296 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3299 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3300 if !changed_gitlinks.is_empty() {
3301 let listed = list_paths(&changed_gitlinks);
3302 bail!(
3303 "the editing call added or changed a gitlink at {listed}. It was staged but not \
3304 committed because the referenced repository objects might exist only inside \
3305 this worktree. The worktree was kept for recovery."
3306 );
3307 }
3308 let mut subject = self.clean_title(preferred_subject)?;
3309 if subject.trim().is_empty() {
3310 subject = self.clean_title(fallback_subject)?;
3311 }
3312 self.commit_staged_changes(cwd, &subject).map_err(|e| {
3313 e.with_message(format!(
3314 "could not commit changes in {}: {}. The staged files were kept.",
3315 cwd.display(),
3316 e.last_line()
3317 ))
3318 })?;
3319 if has_tracked_or_staged_work(cwd)? {
3320 bail!(
3321 "the commit in {} left additional uncommitted files. They were kept for \
3322 recovery.",
3323 cwd.display()
3324 );
3325 }
3326 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3327 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3328 artifacts.report(cwd);
3329 Ok(true)
3330 }
3331
3332 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3333 let after = ignored_untracked_state(cwd)?;
3334 self.git_at_without_automation(cwd, &["add", "-u"])?;
3335 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3336 if paths.is_empty() {
3337 return Ok(());
3338 }
3339 let mut input = Vec::new();
3340 for path in paths {
3341 input.extend(os_str_bytes(path.as_os_str())?);
3342 input.push(0);
3343 }
3344 let argv = git_without_automation_argv(&[
3345 "--literal-pathspecs",
3346 "add",
3347 "--pathspec-from-file=-",
3348 "--pathspec-file-nul",
3349 ]);
3350 proc::run_with_input_bytes(
3351 &argv,
3352 &self.git_opts(Some(cwd), true).stop_descendants(true),
3353 &input,
3354 )?;
3355 Ok(())
3356 }
3357
3358 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3361 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3362 .map(|_| ())
3363 }
3364
3365 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3372 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3373 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3374 .trim()
3375 .parse()
3376 .unwrap_or(0)
3377 }
3378
3379 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3383 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3384 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3385 .lines()
3386 .map(str::to_string)
3387 .collect()
3388 }
3389
3390 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3405 let ancestor = self
3406 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3407 .is_ok();
3408 if !ancestor {
3409 return None;
3410 }
3411 let range = format!("{earlier}..{later}");
3412 Some(
3413 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3414 .lines()
3415 .map(str::to_string)
3416 .collect(),
3417 )
3418 }
3419
3420 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3423 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3424 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3425 .lines()
3426 .map(str::trim)
3427 .filter(|line| !line.is_empty())
3428 .map(str::to_string)
3429 .collect()
3430 }
3431
3432 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3448 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3449 self.git_try_at(
3450 Some(cwd),
3451 &["diff", "--name-only", "--no-renames", "-z", &range],
3452 )
3453 .split('\0')
3454 .filter(|path| !path.is_empty())
3455 .map(str::to_string)
3456 .collect()
3457 }
3458
3459 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3462 self.git_try_at(
3463 Some(cwd),
3464 &[
3465 "diff",
3466 "--name-only",
3467 "--no-renames",
3468 "-z",
3469 &format!("{from}..{to}"),
3470 ],
3471 )
3472 .split('\0')
3473 .filter(|path| !path.is_empty())
3474 .map(str::to_string)
3475 .collect()
3476 }
3477
3478 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3481 let base_ref = self.base_ref(cwd, base);
3482 let out = self
3483 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3484 .map_err(|e| {
3485 spar_err!(
3486 "could not find where {refname} and {base_ref} diverged. {}",
3487 e.last_line()
3488 )
3489 })?;
3490 let sha = out.trim().to_string();
3491 if sha.is_empty() {
3492 bail!("{refname} and {base_ref} share no history");
3493 }
3494 Ok(sha)
3495 }
3496
3497 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3498 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3499 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3500 full.trim().to_string()
3501 }
3502
3503 pub fn rewrite_commits_if_needed(
3516 &self,
3517 cwd: &Path,
3518 base: &str,
3519 floor: Option<&str>,
3520 ) -> Result<()> {
3521 let base_ref = self.base_ref(cwd, base);
3522 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3523 self.report_commits_left_alone(cwd, &base_ref, floor);
3524
3525 let range = format!("{floor}..HEAD");
3526 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3527
3528 let offenders = raw
3529 .split('\x1e')
3530 .filter_map(|entry| entry.split_once('\0'))
3531 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3532 .count();
3533 if offenders == 0 {
3534 return Ok(());
3535 }
3536 logdim!("{offenders} commit message(s) violated style rules, rewriting");
3537
3538 let exe = self_binary()?;
3539 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3540
3541 let argv: Vec<String> = [
3542 "git",
3543 "filter-branch",
3544 "-f",
3545 "--msg-filter",
3546 &filter,
3547 &range,
3548 ]
3549 .iter()
3550 .map(|s| s.to_string())
3551 .collect();
3552 let opts = ExecOpts::new()
3553 .cwd(cwd)
3554 .check(false)
3555 .timeout_secs(600)
3556 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3557 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3558 .env(
3559 "SPAR_BAN_AI_ATTRIBUTION",
3560 bool_env(self.style.ban_ai_attribution),
3561 );
3562 let _ = proc::run(&argv, &opts);
3563
3564 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3565 if !style::violations(&after, &self.style).is_empty() {
3566 bail!(
3567 "commit messages still violate style rules after a rewrite in {}.",
3568 cwd.display()
3569 );
3570 }
3571 Ok(())
3572 }
3573
3574 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3579 if floor == base_ref {
3580 return;
3581 }
3582 let raw = self.git_try_at(
3583 Some(cwd),
3584 &[
3585 "log",
3586 &format!("{base_ref}..{floor}"),
3587 "--format=%H%x00%B%x1e",
3588 ],
3589 );
3590 let left: Vec<&str> = raw
3591 .split('\x1e')
3592 .filter_map(|entry| entry.split_once('\0'))
3593 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3594 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3595 .collect();
3596 if left.is_empty() {
3597 return;
3598 }
3599 logdim!(
3600 "{} commit message(s) already on the branch break the style rules and were left \
3601 alone: {}. spar does not rewrite commits it did not write.",
3602 left.len(),
3603 left.join(", ")
3604 );
3605 }
3606
3607 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3622 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3623 if let Some(head) = expected {
3624 self.refuse_push_that_drops(cwd, head)?;
3625 }
3626 let refspec = format!("HEAD:{branch}");
3627 let lease = match expected {
3628 Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
3629 None => "--force-with-lease".to_string(),
3630 };
3631 let pushed = self
3632 .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
3633 .map(|_| ())
3634 .map_err(|e| {
3635 spar_err!(
3636 "could not push to origin/{branch}. {}\nCheck push access and whether the \
3637 branch moved under you.",
3638 e.last_line()
3639 )
3640 });
3641 self.record_write(pushed)
3642 }
3643
3644 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
3652 let head = self.head_oid_checked(cwd)?;
3653 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
3654 return Ok(());
3655 }
3656 bail!(
3657 "refusing to push {head} because it does not contain the reviewed head {expected}. \
3658 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
3659 the intended commits on top of it before resuming.",
3660 cwd.display()
3661 )
3662 }
3663
3664 pub fn push_split_branch(
3673 &self,
3674 cwd: &Path,
3675 branch: &str,
3676 ) -> std::result::Result<(), SplitPushError> {
3677 let remote_ref = format!("refs/heads/{branch}");
3678 let lease = format!("--force-with-lease={remote_ref}:");
3679 let refspec = format!("HEAD:{remote_ref}");
3680 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
3681 Ok(_) => Ok(()),
3682 Err(push_error) => {
3683 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
3684 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
3685 reconcile_failed_split_push(branch, push_error, local, remote)
3686 }
3687 };
3688 self.record_write(result)
3689 }
3690
3691 pub fn gh(&self, args: &[&str]) -> Result<String> {
3694 self.gh_at(None, args)
3695 }
3696
3697 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
3698 let mut argv = vec!["gh".to_string()];
3699 argv.extend(args.iter().map(|s| s.to_string()));
3700 proc::run(
3701 &argv,
3702 &ExecOpts::new()
3703 .cwd(cwd.unwrap_or(&self.root))
3704 .timeout_secs(300),
3705 )
3706 }
3707
3708 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3714 let mut argv = vec!["gh".to_string()];
3715 argv.extend(args.iter().map(|s| s.to_string()));
3716 proc::run(
3717 &argv,
3718 &ExecOpts::new()
3719 .cwd(&self.root)
3720 .timeout_secs(300)
3721 .stdin(stdin),
3722 )
3723 }
3724
3725 pub fn gh_try(&self, args: &[&str]) -> String {
3726 let mut argv = vec!["gh".to_string()];
3727 argv.extend(args.iter().map(|s| s.to_string()));
3728 proc::run(
3729 &argv,
3730 &ExecOpts::new()
3731 .cwd(&self.root)
3732 .check(false)
3733 .timeout_secs(300),
3734 )
3735 .unwrap_or_default()
3736 }
3737
3738 pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
3743 let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
3744 for (name, value) in headers {
3745 argv.push("-H".to_string());
3746 argv.push(format!("{name}: {value}"));
3747 }
3748 argv.push(endpoint.to_string());
3749 let out = proc::exec(
3750 &argv,
3751 &ExecOpts::new()
3752 .cwd(&self.root)
3753 .check(false)
3754 .timeout_secs(300),
3755 )?;
3756 parse_api_response(&out.stdout)
3757 .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
3758 }
3759
3760 pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
3762 let text = self.gh(&[
3763 "pr",
3764 "list",
3765 "--state",
3766 "open",
3767 "--limit",
3768 "500",
3769 "--json",
3770 PR_SWEEP_FIELDS,
3771 ])?;
3772 serde_json::from_str(&text)
3773 .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
3774 }
3775
3776 pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
3778 let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
3779 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3780 }
3781
3782 pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
3787 let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
3789 if slug.is_empty() || login.is_empty() {
3790 return None;
3791 }
3792 let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
3793 let resp = self.gh_api_raw(&path, &[]).ok()?;
3794 match resp.status {
3795 200 => {
3796 let body: Value = serde_json::from_str(&resp.body).ok()?;
3797 Some(body.get("state")?.as_str()? == "active")
3798 }
3799 404 => Some(false),
3800 _ => None,
3801 }
3802 }
3803
3804 pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
3811 let text = self
3812 .gh(&[
3813 "api",
3814 "graphql",
3815 "-F",
3816 "owner={owner}",
3817 "-F",
3818 "repo={repo}",
3819 "-F",
3820 &format!("number={number}"),
3821 "-f",
3822 &format!("query={REQUESTS_QUERY}"),
3823 ])
3824 .ok()?;
3825 parse_review_requests(&text)
3826 }
3827
3828 pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
3830 if from.is_empty() || to.is_empty() {
3831 return None;
3832 }
3833 let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
3834 self.gh_try(&["api", &path, "--jq", ".total_commits"])
3835 .trim()
3836 .parse()
3837 .ok()
3838 }
3839
3840 pub fn repo_name(&self) -> Option<String> {
3842 let name = self.gh_try(&[
3843 "repo",
3844 "view",
3845 "--json",
3846 "nameWithOwner",
3847 "--jq",
3848 ".nameWithOwner",
3849 ]);
3850 let name = name.trim();
3851 (!name.is_empty()).then(|| name.to_string())
3852 }
3853
3854 pub fn listen_state_path(&self) -> PathBuf {
3855 self.root.join(STATE_DIR).join("state").join("listen.json")
3856 }
3857
3858 pub fn listen_lock_path(&self) -> PathBuf {
3859 self.root.join(STATE_DIR).join("state").join("listen.lock")
3860 }
3861
3862 pub fn viewer_login(&self) -> Result<&str> {
3873 if let Some(login) = self.viewer.get() {
3874 return Ok(login);
3875 }
3876 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3877 let login = if !rest.trim().is_empty() {
3878 rest.trim().to_string()
3879 } else {
3880 self.gh(&[
3883 "api",
3884 "graphql",
3885 "-f",
3886 "query={ viewer { login } }",
3887 "--jq",
3888 ".data.viewer.login",
3889 ])
3890 .map_err(|e| {
3891 spar_err!(
3892 "could not find out who `gh` is authenticated as, so spar cannot tell its \
3893 own comments from anybody else's. {}\nRun `gh auth status`.",
3894 e.last_line()
3895 )
3896 })?
3897 .trim()
3898 .to_string()
3899 };
3900 if login.is_empty() {
3901 bail!("`gh` reported an empty login. Run `gh auth status`.");
3902 }
3903 Ok(self.viewer.get_or_init(|| login))
3904 }
3905
3906 pub fn read_issue(&self, number: i64) -> Result<Issue> {
3912 let text = self
3913 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
3914 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3915 serde_json::from_str(&text)
3916 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3917 }
3918
3919 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3920 let mut issues = Vec::new();
3921 for number in numbers {
3922 let text = self
3923 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
3924 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3925 let issue: Issue = serde_json::from_str(&text)
3926 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3927 if issue.is_closed() {
3928 crate::log!("issue #{number} is closed, skipping");
3929 continue;
3930 }
3931 issues.push(issue);
3932 }
3933 if issues.is_empty() {
3934 bail!("no open issues to work on");
3935 }
3936 Ok(issues)
3937 }
3938
3939 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3945 #[derive(Deserialize)]
3946 struct Row {
3947 number: i64,
3948 }
3949 let text = self.gh(&[
3950 kind,
3951 "list",
3952 "--state",
3953 "open",
3954 "--limit",
3955 &FETCH_CEILING.to_string(),
3956 "--json",
3957 "number",
3958 ])?;
3959 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3960 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3961 numbers.sort_unstable();
3962
3963 let noun = if kind == "issue" { "issues" } else { "PRs" };
3964 let found = numbers.len();
3965 if min_number > 0 {
3966 numbers.retain(|n| *n >= min_number);
3967 let skipped = found - numbers.len();
3968 if skipped > 0 {
3969 crate::log!("{skipped} open {noun} below #{min_number} skipped");
3970 }
3971 }
3972 if found >= FETCH_CEILING {
3973 crate::log!(
3974 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3975 considered."
3976 );
3977 }
3978 if numbers.len() > limit {
3979 crate::log!(
3980 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3981 explicitly.",
3982 numbers.len()
3983 );
3984 numbers.truncate(limit);
3985 }
3986 Ok(numbers)
3987 }
3988
3989 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3991 self.open_numbers("issue", limit, min_number)
3992 }
3993
3994 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3995 self.open_numbers("pr", limit, min_number)
3996 }
3997
3998 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3999 self.branch_prs(branch, "open").into_iter().next()
4000 }
4001
4002 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4005 let text = self.gh(&[
4006 "pr",
4007 "list",
4008 "--head",
4009 branch,
4010 "--base",
4011 base,
4012 "--state",
4013 "open",
4014 "--json",
4015 "number,url,title,baseRefName",
4016 ])?;
4017 pr_for_base(&text, branch, base)
4018 }
4019
4020 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4024 self.branch_prs(branch, "all")
4025 }
4026
4027 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4028 let text = self.gh_try(&[
4029 "pr",
4030 "list",
4031 "--head",
4032 branch,
4033 "--state",
4034 state,
4035 "--json",
4036 "number,url,title",
4037 ]);
4038 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4039 }
4040
4041 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4047 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4048 let text = self
4049 .gh(&[
4050 "api",
4051 &path,
4052 "--jq",
4053 "if .pull_request then \"pr\" else \"issue\" end",
4054 ])
4055 .map_err(|e| {
4056 spar_err!(
4057 "no issue or pull request #{number} in this repository. {}",
4058 e.last_line()
4059 )
4060 })?;
4061 match text.trim() {
4062 "pr" => Ok(ItemKind::Pr),
4063 "issue" => Ok(ItemKind::Issue),
4064 other => Err(spar_err!(
4065 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4066 )),
4067 }
4068 }
4069
4070 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4081 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4082 if let Some(pr) = mine {
4083 return Ok(Some(pr));
4084 }
4085 let listed = match self.gh(&[
4086 "pr",
4087 "list",
4088 "--state",
4089 "open",
4090 "--limit",
4091 &FETCH_CEILING.to_string(),
4092 "--json",
4093 "number,url,title,baseRefName,closingIssuesReferences",
4094 ]) {
4095 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4096 spar_err!("could not read the pull request list while looking for #{issue}")
4097 })?,
4098 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4099 };
4100 if listed.len() >= FETCH_CEILING {
4101 logwarn!(
4102 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
4103 );
4104 }
4105 let default = self.default_branch(base);
4106 if !base.eq_ignore_ascii_case(&default) {
4107 logwarn!(
4111 "#{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."
4112 );
4113 }
4114 Ok(pick_linked_pr(&listed, issue))
4115 }
4116
4117 fn pr_for_branch_failing_closed(
4119 &self,
4120 branch: &str,
4121 base: &str,
4122 issue: i64,
4123 ) -> Result<Option<PrRef>> {
4124 match self.try_pr_for_branch(branch, base) {
4125 Ok(found) => Ok(found),
4126 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4127 }
4128 }
4129
4130 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4139 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4140 return Err(spar_err!(
4141 "could not check whether a pull request already exists for #{issue}: {}. \
4142 Refusing to implement over the top of work that may already be open.",
4143 e.last_line()
4144 ));
4145 }
4146 logwarn!(
4147 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4148 whether a pull request already exists for it.",
4149 e.last_line()
4150 );
4151 Ok(())
4152 }
4153
4154 pub fn pr_view(&self, number: i64) -> Result<PrView> {
4155 let text = self.gh(&[
4156 "pr",
4157 "view",
4158 &number.to_string(),
4159 "--json",
4160 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
4161 ])?;
4162 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4163 }
4164
4165 fn try_pr_state(&self, number: i64) -> Result<String> {
4166 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4167 serde_json::from_str::<Value>(text.trim())
4168 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4169 .get("state")
4170 .and_then(Value::as_str)
4171 .map(str::to_string)
4172 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4173 }
4174
4175 pub fn pr_state(&self, number: i64) -> String {
4176 self.try_pr_state(number).unwrap_or_default()
4177 }
4178
4179 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
4181 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
4182 let oid = serde_json::from_str::<Value>(&text)
4183 .ok()
4184 .and_then(|value| {
4185 value
4186 .get("headRefOid")
4187 .and_then(Value::as_str)
4188 .map(str::trim)
4189 .filter(|oid| !oid.is_empty())
4190 .map(str::to_string)
4191 })
4192 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
4193 Ok(oid)
4194 }
4195
4196 pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
4200 let pull = self.pr_head_oid(pr.number)?;
4201 let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
4202 None
4203 } else {
4204 let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
4205 let listed = self
4206 .git(&["ls-remote", "--heads", "origin", &remote_ref])
4207 .map_err(|e| {
4208 spar_err!(
4209 "could not read where {} is on origin: {}",
4210 pr.head_ref_name.trim(),
4211 e.last_line()
4212 )
4213 })?;
4214 remote_head_oid(&listed, &remote_ref)?
4215 };
4216 Ok(LiveHead { pull, branch })
4217 }
4218
4219 pub fn create_pr(
4220 &self,
4221 cwd: &Path,
4222 branch: &str,
4223 base: &str,
4224 title: &str,
4225 body: &str,
4226 ) -> Result<PrRef> {
4227 let title = self.record_failed_write(self.clean_title(title))?;
4228 let body = self.record_failed_write(self.clean(body))?;
4229 let mut argv = vec![
4230 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
4231 ];
4232 if self.drafts != Drafts::Never {
4233 argv.push("--draft");
4234 }
4235 let created = self.gh_at(Some(cwd), &argv);
4236 let found = self.try_pr_for_branch(branch, base);
4237 self.record_write(reconcile_pr_creation(branch, created, found))
4238 }
4239
4240 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
4241 let body = signed(&self.record_failed_write(self.clean(body))?);
4242 let comments = self.record_failed_write(self.try_issue_comments(number))?;
4243 if has_exact_comment(&comments, &body) {
4244 return Ok(());
4245 }
4246 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
4247 let result = match posted {
4248 Ok(_) => Ok(()),
4249 Err(post_error) => {
4250 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4251 }
4252 };
4253 self.record_write(result)
4254 }
4255
4256 fn pr_is_own(&self, number: i64) -> Result<bool> {
4260 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
4261 let author = serde_json::from_str::<Value>(&text)
4262 .ok()
4263 .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
4264 .unwrap_or_default();
4265 let viewer = self.viewer_login()?.to_string();
4266 Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
4267 }
4268
4269 pub fn submit_review(
4282 &self,
4283 number: i64,
4284 verdict: Option<crate::model::ReviewVerdict>,
4285 body: &str,
4286 commit: &str,
4287 ) -> Result<()> {
4288 let own = match verdict {
4289 Some(_) => self.record_failed_write(self.pr_is_own(number))?,
4290 None => false,
4291 };
4292 let text = match (verdict, own) {
4293 (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
4294 _ => body.to_string(),
4295 };
4296 let body = signed(&self.record_failed_write(self.clean(&text))?);
4297 let viewer = self.record_failed_write(self.viewer_login())?.to_string();
4298 let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
4299 if has_viewer_review(&reviews, &viewer, &body, commit) {
4300 return Ok(());
4301 }
4302 let event = match (verdict, own) {
4303 (Some(v), false) => v.api_event(),
4304 _ => "COMMENT",
4305 };
4306 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
4307 let posted = self.gh(&[
4308 "api",
4309 "-X",
4310 "POST",
4311 &path,
4312 "-f",
4313 &format!("commit_id={commit}"),
4314 "-f",
4315 &format!("event={event}"),
4316 "-f",
4317 &format!("body={body}"),
4318 "--silent",
4319 ]);
4320 let result = match posted {
4321 Ok(_) => Ok(()),
4322 Err(post_error) => reconcile_review_post(
4323 number,
4324 &body,
4325 &viewer,
4326 commit,
4327 post_error,
4328 self.try_pr_reviews(number),
4329 ),
4330 };
4331 self.record_write(result)
4332 }
4333
4334 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
4335 let body = signed(&self.record_failed_write(self.clean(body))?);
4336 let comments = self.record_failed_write(self.try_issue_comments(number))?;
4337 if has_exact_comment(&comments, &body) {
4338 return Ok(());
4339 }
4340 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
4341 let result = match posted {
4342 Ok(_) => Ok(()),
4343 Err(post_error) => {
4344 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4345 }
4346 };
4347 self.record_write(result)
4348 }
4349
4350 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
4355 self.comment_issue(number, body)?;
4356 let n = number.to_string();
4357 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
4358 Ok(_) => Ok(()),
4359 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
4361 spar_err!(
4362 "commented on #{number} but could not close it: {}",
4363 e.last_line()
4364 )
4365 }),
4366 };
4367 self.record_write(closed)
4368 }
4369
4370 pub fn edit_issue_body(
4385 &self,
4386 number: i64,
4387 expected: &str,
4388 body: &str,
4389 inserted: &str,
4390 ) -> Result<()> {
4391 let cleaned = self.record_failed_write(self.clean(inserted))?;
4392 if cleaned.trim() != inserted.trim() {
4393 return self.record_failed_write(Err(spar_err!(
4394 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
4395 )));
4396 }
4397 let current = self.record_failed_write(self.issue_body(number))?;
4398 if current != expected {
4399 return self.record_failed_write(Err(spar_err!(
4400 "the body of #{number} changed since it was read, so it was left alone rather \
4401 than written over."
4402 )));
4403 }
4404 let edited = self.gh_stdin(
4405 &["issue", "edit", &number.to_string(), "--body-file", "-"],
4406 body,
4407 );
4408 let result = match edited {
4409 Ok(_) => Ok(()),
4410 Err(edit_error) => {
4411 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
4412 }
4413 };
4414 self.record_write(result)
4415 }
4416
4417 pub fn issue_body(&self, number: i64) -> Result<String> {
4419 #[derive(Deserialize)]
4420 struct Row {
4421 #[serde(default)]
4422 body: Option<String>,
4423 }
4424 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
4425 let row: Row = serde_json::from_str(text.trim())
4426 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4427 Ok(row.body.unwrap_or_default())
4428 }
4429
4430 pub fn open_issue_rows(&self) -> Vec<Issue> {
4435 let text = self.gh_try(&[
4436 "issue",
4437 "list",
4438 "--state",
4439 "open",
4440 "--limit",
4441 &FETCH_CEILING.to_string(),
4442 "--json",
4443 "number,title,body,labels,state,stateReason,url",
4444 ]);
4445 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
4446 }
4447
4448 pub fn open_pr_rows(&self) -> Vec<PrRow> {
4450 let text = self.gh_try(&[
4451 "pr",
4452 "list",
4453 "--state",
4454 "open",
4455 "--limit",
4456 &FETCH_CEILING.to_string(),
4457 "--json",
4458 "number,title,changedFiles,additions,deletions",
4459 ]);
4460 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
4461 }
4462
4463 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
4464 self.create_issue_apart_from(title, body, None)
4465 }
4466
4467 pub fn create_issue_apart_from(
4468 &self,
4469 title: &str,
4470 body: &str,
4471 apart_from: Option<i64>,
4472 ) -> Result<String> {
4473 let title = self.record_failed_write(self.clean_title(title))?;
4474 let body = self.record_failed_write(self.clean_issue_body(body))?;
4475 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
4476 let result = match created {
4477 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
4478 created => {
4479 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
4480 reconcile_issue_creation(&title, created, found)
4481 }
4482 };
4483 self.record_write(result)
4484 }
4485}
4486
4487#[derive(Debug, Clone)]
4489pub struct ExistingIssue {
4490 pub number: i64,
4491 pub url: String,
4492 pub title: String,
4493 pub body: String,
4494 pub open: bool,
4495}
4496
4497impl Repo {
4498 pub(crate) fn try_exact_issue_apart_from(
4499 &self,
4500 title: &str,
4501 body: &str,
4502 apart_from: Option<i64>,
4503 ) -> Result<Option<ExistingIssue>> {
4504 #[derive(Deserialize)]
4505 #[serde(rename_all = "camelCase")]
4506 struct Row {
4507 number: i64,
4508 #[serde(default)]
4509 title: String,
4510 #[serde(default)]
4511 url: String,
4512 #[serde(default)]
4513 body: Option<String>,
4514 #[serde(default)]
4515 state: String,
4516 }
4517
4518 let text = self.gh(&[
4519 "issue",
4520 "list",
4521 "--state",
4522 "all",
4523 "--limit",
4524 "100",
4525 "--json",
4526 "number,title,url,body,state,stateReason",
4527 ])?;
4528 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4529 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
4530 Ok(rows
4531 .into_iter()
4532 .filter(|row| Some(row.number) != apart_from)
4533 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
4534 .map(|row| ExistingIssue {
4535 number: row.number,
4536 url: row.url,
4537 title: row.title,
4538 body: row.body.unwrap_or_default(),
4539 open: row.state.eq_ignore_ascii_case("open"),
4540 }))
4541 }
4542
4543 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
4551 self.find_similar_issue_apart_from(title, body, None)
4552 }
4553
4554 pub fn find_similar_issue_apart_from(
4562 &self,
4563 title: &str,
4564 body: &str,
4565 apart_from: Option<i64>,
4566 ) -> Option<ExistingIssue> {
4567 self.try_similar_issue(title, body, apart_from, true)
4568 .ok()
4569 .flatten()
4570 }
4571
4572 pub fn try_find_similar_issue_apart_from(
4574 &self,
4575 title: &str,
4576 body: &str,
4577 apart_from: Option<i64>,
4578 ) -> Result<Option<ExistingIssue>> {
4579 self.try_similar_issue(title, body, apart_from, false)
4580 }
4581
4582 fn try_similar_issue(
4584 &self,
4585 title: &str,
4586 body: &str,
4587 apart_from: Option<i64>,
4588 include_closed: bool,
4589 ) -> Result<Option<ExistingIssue>> {
4590 #[derive(Deserialize)]
4591 #[serde(rename_all = "camelCase")]
4592 struct Row {
4593 number: i64,
4594 #[serde(default)]
4595 title: String,
4596 #[serde(default)]
4597 url: String,
4598 #[serde(default)]
4599 body: String,
4600 #[serde(default)]
4601 state: String,
4602 }
4603 if title.trim().is_empty() {
4604 return Ok(None);
4605 }
4606 let query: String = title
4609 .chars()
4610 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4611 .take(120)
4612 .collect();
4613 let state = if include_closed { "all" } else { "open" };
4622 let text = self.gh(&[
4623 "issue",
4624 "list",
4625 "--state",
4626 state,
4627 "--limit",
4628 "100",
4629 "--search",
4630 query.trim(),
4631 "--json",
4632 "number,title,url,body,state,stateReason",
4633 ])?;
4634 let rows: Vec<Row> = serde_json::from_str(text.trim())
4635 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
4636
4637 Ok(rows
4638 .into_iter()
4639 .filter(|row| Some(row.number) != apart_from)
4640 .find(|row| {
4641 row.title.trim().eq_ignore_ascii_case(title.trim())
4642 || textsim::covers(title, body, &row.title, &row.body)
4643 })
4644 .map(|row| ExistingIssue {
4645 number: row.number,
4646 url: row.url,
4647 title: row.title,
4648 open: row.state.eq_ignore_ascii_case("open"),
4649 body: row.body,
4650 }))
4651 }
4652
4653 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
4655 #[derive(Deserialize)]
4656 struct Row {
4657 title: String,
4658 url: String,
4659 }
4660 let needle = title.trim().to_lowercase();
4661 if needle.is_empty() {
4662 return None;
4663 }
4664 let query: String = title
4666 .chars()
4667 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4668 .take(120)
4669 .collect();
4670 let text = self.gh_try(&[
4671 "issue",
4672 "list",
4673 "--state",
4674 "all",
4675 "--limit",
4676 "100",
4677 "--search",
4678 query.trim(),
4679 "--json",
4680 "number,title,url",
4681 ]);
4682 serde_json::from_str::<Vec<Row>>(text.trim())
4683 .ok()?
4684 .into_iter()
4685 .find(|row| row.title.trim().to_lowercase() == needle)
4686 .map(|row| row.url)
4687 }
4688
4689 pub fn mark_ready(&self, number: i64) -> bool {
4697 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
4698 Ok(_) => true,
4699 Err(e) => {
4700 logdim!(
4701 "PR #{number} is approved but could not be taken out of draft: {}",
4702 e.last_line()
4703 );
4704 false
4705 }
4706 }
4707 }
4708
4709 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
4711 let listed = self
4712 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
4713 .ok()?;
4714 let wanted = format!("branch refs/heads/{branch}");
4715 let mut current: Option<PathBuf> = None;
4716 for line in listed.lines() {
4717 if let Some(path) = line.strip_prefix("worktree ") {
4718 current = Some(PathBuf::from(path));
4719 } else if line == wanted {
4720 return current;
4721 }
4722 }
4723 None
4724 }
4725
4726 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
4742 let branch = self.pr_view(number).ok()?.head_ref_name;
4743 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
4744 return None;
4745 }
4746 let mut released = None;
4747 if let Some(path) = self.worktree_holding(&branch) {
4748 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
4749 return None;
4750 }
4751 if !matches!(
4752 self.has_recoverable_work(&path, Disposable::BuildOutput),
4753 Ok(false)
4754 ) {
4755 return None;
4756 }
4757 match self.remove_worktree_at(&path) {
4758 Ok(true) => released = Some(path),
4759 _ => return None,
4760 }
4761 }
4762 match self.delete_branch_if_safe(&branch) {
4763 Ok(true) => {
4764 self.forget_branch(&branch);
4765 Some(match released {
4766 Some(path) => format!(
4767 " The worktree at {} was released and branch {branch} deleted.",
4768 path.display()
4769 ),
4770 None => format!(" Branch {branch} was deleted."),
4771 })
4772 }
4773 _ => released.map(|path| {
4774 format!(
4775 " The worktree at {} was released, but branch {branch} was kept.",
4776 path.display()
4777 )
4778 }),
4779 }
4780 }
4781
4782 pub fn merge_pr(&self, number: i64) -> Result<()> {
4786 let n = number.to_string();
4787 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4788 Ok(_) => Ok(()),
4789 Err(e) => {
4790 if self.pr_state(number) == "MERGED" {
4791 let finished = self
4792 .finish_merged_branch_cleanup(number)
4793 .unwrap_or_default();
4794 logdim!(
4795 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4796 e.last_line()
4797 );
4798 Ok(())
4799 } else {
4800 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4801 }
4802 }
4803 };
4804 self.record_write(merged)
4805 }
4806
4807 pub fn merge_pr_at_head(
4809 &self,
4810 number: i64,
4811 expected_head: &str,
4812 delete_branch: bool,
4813 ) -> Result<()> {
4814 let n = number.to_string();
4815 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4816 Ok(_) => Ok(()),
4817 Err(e) => {
4818 if self.pr_state(number) == "MERGED" {
4819 let finished = self
4820 .finish_merged_branch_cleanup(number)
4821 .unwrap_or_default();
4822 logdim!(
4823 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4824 e.last_line()
4825 );
4826 Ok(())
4827 } else {
4828 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4829 }
4830 }
4831 };
4832 self.record_write(merged)
4833 }
4834
4835 pub fn followups_path(&self) -> PathBuf {
4840 self.root.join(STATE_DIR).join("followups.md")
4841 }
4842
4843 pub fn brainstorms_dir(&self) -> PathBuf {
4848 self.root.join(STATE_DIR).join("brainstorms")
4849 }
4850
4851 pub fn worked_followups_path(&self) -> PathBuf {
4859 self.root.join(STATE_DIR).join("followups.done.md")
4860 }
4861
4862 pub fn spend_path(&self) -> PathBuf {
4865 self.root.join(STATE_DIR).join("state").join("spend.json")
4866 }
4867
4868 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4874 if calls.is_empty() {
4875 return Ok(());
4876 }
4877 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4878 .ok()
4879 .and_then(|text| serde_json::from_str(&text).ok())
4880 .unwrap_or_default();
4881 all.extend(calls.iter().cloned());
4882 if let Some(parent) = self.spend_path().parent() {
4883 let _ = std::fs::create_dir_all(parent);
4884 }
4885 write_json_atomic(&self.spend_path(), &all)
4886 }
4887
4888 pub fn implementors_path(&self) -> PathBuf {
4894 self.root
4895 .join(STATE_DIR)
4896 .join("state")
4897 .join("implementors.json")
4898 }
4899
4900 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4901 std::fs::read_to_string(self.implementors_path())
4902 .ok()
4903 .and_then(|text| serde_json::from_str(&text).ok())
4904 .unwrap_or_default()
4905 }
4906
4907 pub fn remember_implementor(&self, issue: i64, agent: &str) {
4910 let mut known = self.read_implementors();
4911 known.insert(issue, agent.to_string());
4912 if let Some(parent) = self.implementors_path().parent() {
4913 let _ = std::fs::create_dir_all(parent);
4914 }
4915 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4916 logdim!("could not record who implemented #{issue}: {e}");
4917 }
4918 }
4919
4920 pub fn contested_path(&self) -> PathBuf {
4925 self.root
4926 .join(STATE_DIR)
4927 .join("state")
4928 .join("contested.json")
4929 }
4930
4931 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4933 std::fs::read_to_string(self.contested_path())
4934 .ok()
4935 .and_then(|text| serde_json::from_str(&text).ok())
4936 .unwrap_or_default()
4937 }
4938
4939 pub fn remember_contested(&self, items: &[ContestedItem]) {
4944 if items.is_empty() {
4945 return;
4946 }
4947 let mut known = self.read_contested();
4948 for item in items {
4949 known.insert(
4950 item.issue,
4951 Contested {
4952 title: item.title.clone(),
4953 positions: item.positions.clone(),
4954 reasons: item.reasons.clone(),
4955 },
4956 );
4957 }
4958 if let Some(parent) = self.contested_path().parent() {
4959 let _ = std::fs::create_dir_all(parent);
4960 }
4961 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4962 logdim!("could not record the contested issues: {e}");
4963 }
4964 }
4965
4966 pub fn forget_contested(&self, numbers: &[i64]) {
4968 let mut known = self.read_contested();
4969 let before = known.len();
4970 known.retain(|number, _| !numbers.contains(number));
4971 if known.len() == before {
4972 return;
4973 }
4974 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4975 logdim!("could not update the contested issues: {e}");
4976 }
4977 }
4978
4979 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4980 self.root
4981 .join(STATE_DIR)
4982 .join("state")
4983 .join(format!("checkin-{number}.json"))
4984 }
4985
4986 pub fn review_record_path(&self, number: i64) -> PathBuf {
4988 self.root
4989 .join(STATE_DIR)
4990 .join("state")
4991 .join(format!("review-{number}.json"))
4992 }
4993
4994 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
4997 let path = self.review_record_path(number);
4998 let text = std::fs::read_to_string(&path).ok()?;
4999 match serde_json::from_str(&text) {
5000 Ok(record) => Some(record),
5001 Err(e) => {
5002 logdim!("could not read {}: {e}", path.display());
5003 None
5004 }
5005 }
5006 }
5007
5008 pub fn write_review_record(
5009 &self,
5010 number: i64,
5011 record: &crate::model::ReviewRecord,
5012 ) -> Result<()> {
5013 write_json_atomic(&self.review_record_path(number), record)
5014 }
5015
5016 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5031 let path = self.followups_path();
5032 let heading = format!("## {}", title.trim());
5033 for seen in [&path, &self.worked_followups_path()] {
5034 if let Ok(existing) = std::fs::read_to_string(seen) {
5035 if has_heading(&existing, title) {
5036 logdim!("follow-up already noted: {title}");
5037 return Followup::Covered(format!("note: {}", title.trim()));
5038 }
5039 }
5040 }
5041 if let Some(parent) = path.parent() {
5042 let _ = std::fs::create_dir_all(parent);
5043 }
5044 use std::io::Write;
5045 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5052 match std::fs::OpenOptions::new()
5053 .create(true)
5054 .append(true)
5055 .open(&path)
5056 {
5057 Ok(mut file) => match file.write_all(entry.as_bytes()) {
5058 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5059 Err(e) => {
5060 logdim!("could not write {}: {e}", path.display());
5061 Followup::Failed
5062 }
5063 },
5064 Err(e) => {
5065 logdim!("could not write {}: {e}", path.display());
5066 Followup::Failed
5067 }
5068 }
5069 }
5070
5071 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5076 let path = self.worked_followups_path();
5077 if let Some(parent) = path.parent() {
5078 let _ = std::fs::create_dir_all(parent);
5079 }
5080 use std::io::Write;
5081 let entry = format!(
5082 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5083 title.trim(),
5084 body.trim()
5085 );
5086 if let Ok(mut file) = std::fs::OpenOptions::new()
5087 .create(true)
5088 .append(true)
5089 .open(&path)
5090 {
5091 let _ = file.write_all(entry.as_bytes());
5092 }
5093 }
5094
5095 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5104 self.root
5105 .join(STATE_DIR)
5106 .join("reviews")
5107 .join(format!("pr-{number}.md"))
5108 }
5109
5110 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5116 let path = self.pending_comment_path(number);
5117 if let Some(parent) = path.parent() {
5118 std::fs::create_dir_all(parent)
5119 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5120 }
5121 std::fs::write(&path, text)
5122 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5123 Ok(path)
5124 }
5125
5126 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5127 std::fs::read_to_string(self.pending_comment_path(number)).ok()
5128 }
5129
5130 pub fn state_path(&self, number: i64) -> PathBuf {
5131 self.root
5132 .join(STATE_DIR)
5133 .join("state")
5134 .join(format!("pr-{number}.json"))
5135 }
5136
5137 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5138 let path = self.state_path(number);
5139 let text = std::fs::read_to_string(&path).ok()?;
5140 match serde_json::from_str(&text) {
5141 Ok(state) => Some(state),
5142 Err(_) => {
5143 logdim!("could not read {}, starting fresh", path.display());
5144 None
5145 }
5146 }
5147 }
5148
5149 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5150 if let Some(local) = self.read_local_state(pr.number) {
5151 return Some(local);
5152 }
5153 if self.state_store.writes_pr() {
5154 return self.read_pr_state(pr.number);
5155 }
5156 None
5157 }
5158
5159 pub(crate) fn read_state_for_head(
5160 &self,
5161 pr: &PrView,
5162 actual_head: &str,
5163 ) -> Option<PersistedState> {
5164 let local = self
5165 .state_store
5166 .writes_local()
5167 .then(|| self.read_local_state(pr.number))
5168 .flatten();
5169 let remote = self
5170 .state_store
5171 .writes_pr()
5172 .then(|| self.read_pr_state(pr.number))
5173 .flatten();
5174 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5175 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5176 self.remember_checkpoint(pr.number, checkpoint);
5177 }
5178 choose_state_for_head(candidates, actual_head)
5179 }
5180
5181 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5182 self.try_read_pr_state(number).ok().flatten()
5183 }
5184
5185 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5186 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5187 if let Some(state) = parse_state_comment(&body) {
5188 return Ok(Some(state));
5189 }
5190 }
5191 Ok(None)
5192 }
5193
5194 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5195 let remote_state = if self.state_store.writes_pr() {
5196 self.try_read_pr_state(number)
5197 } else {
5198 Ok(None)
5199 };
5200 self.write_state_after_remote_read(number, state, remote_state)
5201 }
5202
5203 fn write_state_after_remote_read(
5204 &self,
5205 number: i64,
5206 state: &PersistedState,
5207 remote_state: Result<Option<PersistedState>>,
5208 ) -> Result<()> {
5209 let remote_checkpoint = if self.state_store.writes_pr() {
5210 self.record_failed_write(remote_state)?
5211 .map(|saved| saved.checkpoint)
5212 .unwrap_or_default()
5213 } else {
5214 0
5215 };
5216 let local_checkpoint = self
5217 .state_store
5218 .writes_local()
5219 .then(|| self.read_local_state(number))
5220 .flatten()
5221 .map(|saved| saved.checkpoint)
5222 .unwrap_or_default();
5223 let mut stamped = state.clone();
5224 stamped.checkpoint = state
5225 .checkpoint
5226 .max(local_checkpoint)
5227 .max(remote_checkpoint)
5228 .max(self.remembered_checkpoint(number))
5229 .saturating_add(1);
5230 self.remember_checkpoint(number, stamped.checkpoint);
5231 if self.state_store.writes_local() {
5232 write_json_atomic(&self.state_path(number), &stamped)?;
5233 }
5234 if self.state_store.writes_pr() {
5235 self.write_pr_state(number, &stamped)?;
5236 }
5237 Ok(())
5238 }
5239
5240 fn remembered_checkpoint(&self, number: i64) -> u64 {
5241 self.checkpoints
5242 .lock()
5243 .unwrap_or_else(std::sync::PoisonError::into_inner)
5244 .get(&number)
5245 .copied()
5246 .unwrap_or_default()
5247 }
5248
5249 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
5250 let mut checkpoints = self
5251 .checkpoints
5252 .lock()
5253 .unwrap_or_else(std::sync::PoisonError::into_inner);
5254 let saved = checkpoints.entry(number).or_default();
5255 *saved = (*saved).max(checkpoint);
5256 }
5257
5258 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5259 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
5263 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
5264 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
5265 if let Some(id) = comment_id {
5266 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5267 let field = format!("body={body}");
5268 let written = self
5269 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
5270 .map(|_| ());
5271 return self.record_write(written);
5272 }
5273 let written = self
5274 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
5275 .map(|_| ());
5276 self.record_write(written)
5277 }
5278
5279 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
5287 self.try_issue_comments(number).unwrap_or_default()
5288 }
5289
5290 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
5291 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
5292 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
5293 }
5294
5295 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
5302 let viewer = self.viewer_login()?.to_string();
5303 let mut mine = Vec::new();
5304 let mut theirs = Vec::new();
5305 for comment in self.try_issue_comments(number)? {
5306 let Some(body) = comment.get("body").and_then(Value::as_str) else {
5307 continue;
5308 };
5309 if !body.contains("spar:state") {
5310 continue;
5311 }
5312 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
5313 continue;
5314 };
5315 let author = comment
5316 .get("user")
5317 .and_then(|u| u.get("login"))
5318 .and_then(Value::as_str)
5319 .unwrap_or_default();
5320 if is_state_comment(body, author, &viewer) {
5321 mine.push((id, body.to_string()));
5322 } else {
5323 theirs.push(format!("@{author}"));
5324 }
5325 }
5326 if !theirs.is_empty() {
5327 logdim!(
5328 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
5329 own state block is patched or deleted.",
5330 theirs.len(),
5331 theirs.join(", ")
5332 );
5333 }
5334 Ok(mine)
5335 }
5336
5337 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
5338 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
5339 }
5340
5341 pub fn clear_state(&self, number: i64) {
5343 let path = self.state_path(number);
5344 let _ = std::fs::remove_file(&path);
5345 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
5346 }
5347
5348 pub fn prune_state(&self) -> Vec<String> {
5355 let base = self.root.join(STATE_DIR).join("state");
5356 let Ok(entries) = std::fs::read_dir(&base) else {
5357 return Vec::new();
5358 };
5359 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
5360 for name in entries
5361 .flatten()
5362 .filter_map(|e| e.file_name().to_str().map(str::to_string))
5363 {
5364 if let Some(number) = state_file_number(&name) {
5365 by_number.entry(number).or_default().push(name);
5366 }
5367 }
5368
5369 let mut removed = Vec::new();
5370 for (number, mut names) in by_number {
5371 if !is_finished(&self.pr_state(number)) {
5372 continue;
5373 }
5374 names.sort();
5375 for name in names {
5376 let _ = std::fs::remove_file(base.join(&name));
5377 removed.push(format!("state {name}"));
5378 }
5379 }
5380 removed
5381 }
5382
5383 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
5387 #[derive(Deserialize)]
5388 struct Row {
5389 number: i64,
5390 }
5391 let numbers = match numbers {
5392 Some(numbers) => numbers,
5393 None => {
5394 let listed: Result<Vec<i64>> = (|| {
5395 let text = self.gh(&[
5396 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
5397 ])?;
5398 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5399 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
5400 Ok(rows.into_iter().map(|row| row.number).collect())
5401 })();
5402 match self.record_failed_write(listed) {
5403 Ok(numbers) => numbers,
5404 Err(e) => {
5405 logdim!("could not inspect pull requests for state cleanup: {e}");
5406 return Vec::new();
5407 }
5408 }
5409 }
5410 };
5411
5412 let mut removed = Vec::new();
5413 for number in numbers {
5414 let state = match self.record_failed_write(self.try_pr_state(number)) {
5415 Ok(state) => state,
5416 Err(e) => {
5417 logdim!("could not inspect PR #{number} for state cleanup: {e}");
5418 continue;
5419 }
5420 };
5421 if !is_finished(&state) {
5422 continue;
5423 }
5424 let comments = match self.record_failed_write(self.try_state_comments(number)) {
5425 Ok(comments) => comments,
5426 Err(e) => {
5427 logdim!("could not inspect state comments on PR #{number}: {e}");
5428 continue;
5429 }
5430 };
5431 for (id, _) in comments {
5432 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5433 let deleted = self
5434 .gh(&["api", "-X", "DELETE", &path, "--silent"])
5435 .map(|_| ());
5436 match self.record_write(deleted) {
5437 Ok(()) => removed.push(format!("state comment on PR #{number}")),
5438 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
5439 }
5440 }
5441 }
5442 removed
5443 }
5444
5445 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
5452 let base = self.root.join(WORKTREE_DIR);
5453 let mut removed = Vec::new();
5454 let known = self.known_branches();
5455
5456 if let Ok(entries) = std::fs::read_dir(&base) {
5457 let mut names: Vec<String> = entries
5458 .flatten()
5459 .filter(|e| e.path().is_dir())
5460 .filter_map(|e| e.file_name().to_str().map(str::to_string))
5461 .collect();
5462 names.sort();
5463
5464 for name in names {
5465 if let Some(rest) = name.strip_prefix("review-") {
5468 let number: i64 = rest.parse().unwrap_or(-1);
5469 if !(force_all || is_finished(&self.pr_state(number))) {
5470 continue;
5471 }
5472 let path = base.join(&name);
5473 if force_all {
5474 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
5475 if !belongs {
5476 return Ok(false);
5477 }
5478 let local_ref = review_ref(number);
5479 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
5480 return Ok(false);
5481 }
5482 let head = self.head_oid_checked(&path)?;
5483 let recorded = self
5484 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
5485 .trim()
5486 .to_string();
5487 Ok(head == recorded)
5488 });
5489 match owned {
5490 Ok(true) => {}
5491 Ok(false) => {
5492 logdim!(
5493 "kept {} because no matching SPAR review reference proves \
5494 ownership",
5495 path.display()
5496 );
5497 continue;
5498 }
5499 Err(e) => {
5500 logdim!(
5501 "kept {} because review ownership could not be verified: {}",
5502 path.display(),
5503 e.last_line()
5504 );
5505 continue;
5506 }
5507 }
5508 } else {
5509 if let Err(e) = self.refuse_review_worktree_changes(number) {
5510 logdim!(
5511 "kept {} because its review state could not be verified as \
5512 disposable: {}",
5513 path.display(),
5514 e.last_line()
5515 );
5516 continue;
5517 }
5518 }
5519 if force_all {
5520 if self.remove_worktree_at_force(&path) {
5521 self.git_try(&["update-ref", "-d", &review_ref(number)]);
5522 }
5523 } else {
5524 self.release_review_worktree(number);
5525 }
5526 if !path.exists() {
5527 removed.push(name);
5528 }
5529 continue;
5530 }
5531 let branch = format!("{}{name}", self.branch_prefix);
5532 if !(force_all || self.worktree_is_done(&branch)) {
5533 continue;
5534 }
5535 if !known.contains_key(&branch) {
5536 logdim!("kept {branch} because it has no branch record");
5537 continue;
5538 }
5539 let path = base.join(&name);
5540 if !force_all {
5541 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
5542 Ok(true) => {
5543 logdim!(
5544 "kept {} because it contains uncommitted changes or ignored files",
5545 path.display()
5546 );
5547 continue;
5548 }
5549 Err(e) => {
5550 logdim!(
5551 "kept {} because its Git state could not be checked: {}",
5552 path.display(),
5553 e.last_line()
5554 );
5555 continue;
5556 }
5557 Ok(false) => {}
5558 }
5559 match self.branch_deletion_is_safe(&branch) {
5560 Ok(true) => {}
5561 Ok(false) => {
5562 logdim!(
5563 "kept {branch} because no surviving ref preserves its tip or \
5564 reflog-only commits"
5565 );
5566 continue;
5567 }
5568 Err(e) => {
5569 logdim!(
5570 "kept {branch} because preservation could not be verified: {}",
5571 e.last_line()
5572 );
5573 continue;
5574 }
5575 }
5576 }
5577 let removed_worktree = if force_all {
5578 self.remove_worktree_at_force(&path)
5579 } else {
5580 match self.remove_worktree_at(&path) {
5581 Ok(removed) => removed,
5582 Err(error) => {
5583 logdim!(
5584 "kept {branch} and {} because removal did not reach a confirmed \
5585 quiet point: {}",
5586 path.display(),
5587 error.last_line()
5588 );
5589 false
5590 }
5591 }
5592 };
5593 if !removed_worktree {
5594 continue;
5595 }
5596 if force_all {
5597 self.git_try(&["branch", "-D", &branch]);
5598 self.forget_branch(&branch);
5599 } else {
5600 match self.delete_branch_if_safe(&branch) {
5601 Ok(true) => self.forget_branch(&branch),
5602 Ok(false) => logdim!(
5603 "kept {branch} because its tip or reflog changed before deletion"
5604 ),
5605 Err(error) => logdim!(
5606 "kept {branch} because deletion safety could not be rechecked: {}",
5607 error.last_line()
5608 ),
5609 }
5610 }
5611 removed.push(name);
5612 }
5613 }
5614 removed.extend(self.prune_branches(force_all));
5615 removed
5616 }
5617
5618 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
5625 let known = self.known_branches();
5626 let branches: Vec<String> = known.keys().cloned().collect();
5627 if branches.is_empty() {
5628 return Vec::new();
5629 }
5630
5631 let checked_out: Vec<String> = self
5632 .git_try(&["worktree", "list", "--porcelain"])
5633 .lines()
5634 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
5635 .collect();
5636
5637 let existing: Vec<String> = self
5640 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
5641 .lines()
5642 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
5643 .collect();
5644
5645 let mut removed = Vec::new();
5646 for branch in branches {
5647 if !existing.contains(&branch) {
5648 self.forget_branch(&branch); continue;
5650 }
5651 if checked_out.contains(&branch) {
5652 continue;
5653 }
5654 if !(force_all || self.worktree_is_done(&branch)) {
5655 continue;
5656 }
5657 if !force_all {
5658 let Some(_record) = known.get(&branch) else {
5659 continue;
5660 };
5661 match self.branch_deletion_is_safe(&branch) {
5662 Ok(true) => {}
5663 Ok(false) => {
5664 logdim!(
5665 "kept {branch} because no surviving ref preserves its tip or \
5666 reflog-only commits"
5667 );
5668 continue;
5669 }
5670 Err(e) => {
5671 logdim!(
5672 "kept {branch} because preservation could not be verified: {}",
5673 e.last_line()
5674 );
5675 continue;
5676 }
5677 }
5678 }
5679 let deleted = if force_all {
5680 self.git(&["branch", "-D", &branch]).map(|_| true)
5681 } else {
5682 self.delete_branch_if_safe(&branch)
5683 };
5684 match deleted {
5685 Ok(true) => {
5686 self.forget_branch(&branch);
5687 removed.push(format!("branch {branch}"));
5688 }
5689 Ok(false) => {
5690 logdim!("kept {branch} because its tip or reflog changed before deletion");
5691 }
5692 Err(e) => {
5693 logdim!("could not delete {branch}: {}", e.last_line());
5696 }
5697 }
5698 }
5699 removed
5700 }
5701
5702 fn worktree_is_done(&self, branch: &str) -> bool {
5704 #[derive(Deserialize)]
5705 struct Row {
5706 state: String,
5707 }
5708 let entry = branch
5709 .strip_prefix(self.branch_prefix.as_str())
5710 .unwrap_or(branch);
5711 if let Some(rest) = entry.strip_prefix("pr-") {
5712 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
5713 }
5714 if entry.starts_with("issue-") || entry.starts_with("split-") {
5718 let text = self.gh_try(&[
5719 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
5720 ]);
5721 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5722 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
5723 }
5724 false
5725 }
5726}
5727
5728pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5740 match probe() {
5741 Ok(value) => Ok(value),
5742 Err(first) => {
5743 logdim!("retrying a worktree probe after: {}", first.last_line());
5744 probe()
5745 }
5746 }
5747}
5748
5749pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5755 let root = std::fs::canonicalize(cwd)
5756 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5757 let mut files = BTreeMap::new();
5758 let mut visited = BTreeSet::new();
5759 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5760 Ok(AttributeState { files })
5761}
5762
5763fn collect_attribute_files(
5764 root: &Path,
5765 repository: &Path,
5766 prefix: &Path,
5767 visited: &mut BTreeSet<PathBuf>,
5768 files: &mut BTreeMap<PathBuf, [u8; 32]>,
5769) -> Result<()> {
5770 let canonical = std::fs::canonicalize(repository)
5771 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5772 if !visited.insert(canonical) {
5773 bail!("submodule recursion revisited {}", repository.display());
5774 }
5775 let entries = index_entries(repository)?;
5776 let mut paths: BTreeSet<PathBuf> = entries
5777 .iter()
5778 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5779 .map(|entry| entry.path.clone())
5780 .collect();
5781 paths.extend(untracked_attribute_paths(repository)?);
5787 for path in paths {
5788 let from_root = prefix.join(&path);
5789 let state = attribute_file_fingerprint(&root.join(&from_root))?;
5790 files.insert(from_root, state);
5791 }
5792 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5793 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5794 continue;
5795 };
5796 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5797 }
5798 Ok(())
5799}
5800
5801pub(crate) fn uncertain_worktree_change(
5804 cwd: &Path,
5805 message: impl Into<String>,
5806) -> crate::error::SparError {
5807 let message = message.into();
5808 let marker = write_recovery_marker(cwd, &message);
5809 let note = match marker {
5810 Ok(path) => format!(" Recovery marker: {}.", path.display()),
5811 Err(e) => format!(
5812 " A recovery marker could not be written: {}.",
5813 e.last_line()
5814 ),
5815 };
5816 crate::error::SparError::uncertain_write(format!("{message}{note}"))
5817}
5818
5819fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5820 use std::sync::atomic::{AtomicU32, Ordering};
5821 static NEXT: AtomicU32 = AtomicU32::new(0);
5822 for _ in 0..1000 {
5823 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5824 let path = cwd.join(format!(
5825 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5826 std::process::id()
5827 ));
5828 let mut options = OpenOptions::new();
5829 options.write(true).create_new(true);
5830 #[cfg(unix)]
5831 {
5832 use std::os::unix::fs::OpenOptionsExt;
5833 options.mode(0o600);
5834 }
5835 match options.open(&path) {
5836 Ok(mut file) => {
5837 file.write_all(detail.as_bytes())
5838 .and_then(|_| file.write_all(b"\n"))
5839 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5840 return Ok(path);
5841 }
5842 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5843 Err(e) => {
5844 return Err(spar_err!(
5845 "could not create a recovery marker in {}: {e}",
5846 cwd.display()
5847 ))
5848 }
5849 }
5850 }
5851 bail!(
5852 "could not choose a free recovery marker name in {}",
5853 cwd.display()
5854 )
5855}
5856
5857fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5862 let mut argv = vec![
5863 "git".to_string(),
5864 "-c".to_string(),
5865 "maintenance.auto=false".to_string(),
5866 "-c".to_string(),
5867 "gc.auto=0".to_string(),
5868 ];
5869 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5870 argv
5871}
5872
5873fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5874 let mut argv = git_without_maintenance_argv(&[]);
5875 argv.extend([
5876 "-c".to_string(),
5877 "core.fsmonitor=".to_string(),
5878 "-c".to_string(),
5879 "commit.gpgsign=false".to_string(),
5880 "-c".to_string(),
5881 "core.hooksPath=/dev/null".to_string(),
5882 ]);
5883 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5884 argv
5885}
5886
5887pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5894 let root = std::fs::canonicalize(cwd)
5895 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5896 let mut files = BTreeMap::new();
5897 let mut ignored = BTreeSet::new();
5898 let mut visited = BTreeSet::new();
5899 collect_untracked_files(
5900 &root,
5901 &root,
5902 Path::new(""),
5903 &mut visited,
5904 &mut files,
5905 &mut ignored,
5906 )?;
5907 Ok(IgnoredState { files, ignored })
5908}
5909
5910fn collect_untracked_files(
5911 root: &Path,
5912 repository: &Path,
5913 prefix: &Path,
5914 visited: &mut BTreeSet<PathBuf>,
5915 files: &mut BTreeMap<PathBuf, UntrackedFile>,
5916 ignored: &mut BTreeSet<PathBuf>,
5917) -> Result<()> {
5918 let canonical = std::fs::canonicalize(repository)
5919 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5920 if !visited.insert(canonical.clone()) {
5921 bail!("submodule recursion revisited {}", canonical.display());
5922 }
5923 let listed = run_git_bytes(
5927 repository,
5928 &["ls-files", "--others", "--exclude-standard", "-z"],
5929 )?;
5930 if !listed.is_empty() && !listed.ends_with(&[0]) {
5931 bail!(
5932 "git returned an unterminated untracked-file list for {}",
5933 repository.display()
5934 );
5935 }
5936
5937 for raw in listed
5938 .split(|byte| *byte == 0)
5939 .filter(|raw| !raw.is_empty())
5940 {
5941 let (relative, nested) = untracked_record(raw, "untracked")?;
5942 let from_root = prefix.join(&relative);
5943 if is_spar_own_path(&from_root) {
5944 continue;
5945 }
5946 let absolute = root.join(&from_root);
5947 let fingerprint = if nested {
5948 nested_repository_fingerprint(&absolute)?
5949 } else {
5950 ignored_file_fingerprint(&absolute)?
5951 };
5952 if files.insert(from_root.clone(), fingerprint).is_some() {
5953 bail!(
5954 "git returned the untracked path more than once: {:?}",
5955 from_root
5956 );
5957 }
5958 }
5959
5960 let ignored_listed = run_git_bytes(
5961 repository,
5962 &[
5963 "ls-files",
5964 "--others",
5965 "--ignored",
5966 "--exclude-standard",
5967 "-z",
5968 ],
5969 )?;
5970 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5971 bail!(
5972 "git returned an unterminated ignored-file list for {}",
5973 repository.display()
5974 );
5975 }
5976 for raw in ignored_listed
5982 .split(|byte| *byte == 0)
5983 .filter(|raw| !raw.is_empty())
5984 {
5985 let (relative, nested) = untracked_record(raw, "ignored")?;
5986 let from_root = prefix.join(relative);
5987 if is_spar_own_path(&from_root) {
5988 continue;
5989 }
5990 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5991 let absolute = root.join(&from_root);
5992 let fingerprint = if nested {
5993 nested_repository_fingerprint(&absolute)?
5994 } else {
5995 ignored_file_fingerprint(&absolute)?
5996 };
5997 slot.insert(fingerprint);
5998 }
5999 if !ignored.insert(from_root.clone()) {
6000 bail!(
6001 "git returned the ignored path more than once: {:?}",
6002 from_root
6003 );
6004 }
6005 }
6006
6007 for link in gitlinks(repository)? {
6008 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6009 continue;
6010 };
6011 collect_untracked_files(
6012 root,
6013 &submodule,
6014 &prefix.join(&link.path),
6015 visited,
6016 files,
6017 ignored,
6018 )?;
6019 }
6020 Ok(())
6021}
6022
6023fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
6024 let argv = git_without_automation_argv(args);
6025 proc::run_bytes(
6026 &argv,
6027 &ExecOpts::new()
6028 .cwd(cwd)
6029 .timeout_secs(30)
6030 .stop_descendants(true),
6031 )
6032}
6033
6034fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
6035 let argv = git_without_automation_argv(args);
6036 proc::run(
6037 &argv,
6038 &ExecOpts::new()
6039 .cwd(cwd)
6040 .timeout_secs(30)
6041 .stop_descendants(true),
6042 )
6043}
6044
6045fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
6046 let path = path.to_str().ok_or_else(|| {
6047 spar_err!(
6048 "cannot verify filtered content for a non-UTF-8 path in {}",
6049 cwd.display()
6050 )
6051 })?;
6052 let path_arg = format!("--path={path}");
6053 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
6054 Ok(Sha256::digest(bytes).into())
6055}
6056
6057fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
6058 let relative = path_from_git_bytes(raw)?;
6059 if relative.is_absolute()
6060 || relative.components().any(|component| {
6061 matches!(
6062 component,
6063 std::path::Component::ParentDir
6064 | std::path::Component::RootDir
6065 | std::path::Component::Prefix(_)
6066 )
6067 })
6068 {
6069 bail!("git returned an unsafe {kind} path: {:?}", relative);
6070 }
6071 Ok(relative)
6072}
6073
6074fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6083 let nested = raw.last() == Some(&b'/');
6084 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6085 if trimmed.is_empty() {
6086 bail!("git returned an empty {kind} path");
6087 }
6088 Ok((safe_git_path(trimmed, kind)?, nested))
6089}
6090
6091fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6092 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6093 if !listed.is_empty() && !listed.ends_with(&[0]) {
6094 bail!(
6095 "git returned an unterminated index listing for {}",
6096 cwd.display()
6097 );
6098 }
6099 let mut entries = Vec::new();
6100 for record in listed
6101 .split(|byte| *byte == 0)
6102 .filter(|record| !record.is_empty())
6103 {
6104 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6105 bail!(
6106 "git returned a malformed index record for {}",
6107 cwd.display()
6108 );
6109 };
6110 let header = &record[..tab];
6111 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6112 if fields.len() != 3 {
6113 bail!(
6114 "git returned a malformed index header for {}",
6115 cwd.display()
6116 );
6117 }
6118 if fields[2] != b"0" {
6119 continue;
6120 }
6121 let mode = std::str::from_utf8(fields[0])
6122 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6123 .to_string();
6124 let oid = std::str::from_utf8(fields[1])
6125 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6126 .to_string();
6127 entries.push(IndexEntry {
6128 path: safe_git_path(&record[tab + 1..], "index")?,
6129 mode,
6130 oid,
6131 });
6132 }
6133 Ok(entries)
6134}
6135
6136fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6138 let listed = run_git_bytes(
6139 cwd,
6140 &[
6141 "ls-files",
6142 "--others",
6143 "--exclude-standard",
6144 "-z",
6145 "--",
6146 ".gitattributes",
6147 ":(glob)**/.gitattributes",
6148 ],
6149 )?;
6150 if !listed.is_empty() && !listed.ends_with(&[0]) {
6151 bail!(
6152 "git returned an unterminated attribute-file listing for {}",
6153 cwd.display()
6154 );
6155 }
6156 listed
6157 .split(|byte| *byte == 0)
6158 .filter(|record| !record.is_empty())
6159 .map(|raw| safe_git_path(raw, "attribute"))
6160 .collect()
6161}
6162
6163fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6164 if !untracked_attribute_paths(cwd)?.is_empty() {
6165 return Ok(true);
6166 }
6167
6168 let index = index_entries(cwd)?
6169 .into_iter()
6170 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6171 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6172 .collect::<BTreeMap<_, _>>();
6173 let head = tree_entries(cwd, "HEAD")?
6174 .into_iter()
6175 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6176 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6177 .collect::<BTreeMap<_, _>>();
6178 if index != head {
6179 return Ok(true);
6180 }
6181
6182 let effective = check_attributes(cwd, index.keys().cloned())?;
6183 let config = CheckoutConfig::read(cwd)?;
6184 for (path, (_mode, oid)) in index {
6185 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6186 return Ok(true);
6187 };
6188 let attributes = effective
6189 .get(&path)
6190 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6191 if allows_expected_crlf(&config, attributes)? {
6192 if worktree.mode == "120000" {
6193 return Ok(true);
6194 }
6195 let (normalized, every_lf_was_crlf) =
6196 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
6197 if !every_lf_was_crlf || normalized != oid {
6198 return Ok(true);
6199 }
6200 } else if worktree.raw_oid != oid {
6201 return Ok(true);
6202 }
6203 }
6204 Ok(false)
6205}
6206
6207fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
6208 Ok(index_entries(cwd)?
6209 .into_iter()
6210 .filter(|entry| entry.mode == "160000")
6211 .map(|entry| Gitlink {
6212 path: entry.path,
6213 oid: entry.oid,
6214 })
6215 .collect())
6216}
6217
6218fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
6219 let mut tracked = BTreeMap::new();
6220 for entry in index_entries(cwd)? {
6221 if entry.mode == "160000" {
6222 continue;
6223 }
6224 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
6225 tracked.insert(
6226 entry.path,
6227 TrackedEntry {
6228 index_mode: entry.mode,
6229 index_oid: entry.oid,
6230 worktree,
6231 },
6232 );
6233 }
6234 Ok(tracked)
6235}
6236
6237fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
6238 let metadata = match std::fs::symlink_metadata(path) {
6239 Ok(metadata) => metadata,
6240 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6241 Err(e) => {
6242 return Err(spar_err!(
6243 "could not inspect tracked file {}: {e}",
6244 path.display()
6245 ))
6246 }
6247 };
6248 let mut fingerprint = Sha256::new();
6249 if metadata.file_type().is_symlink() {
6250 let target = std::fs::read_link(path)
6251 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
6252 let bytes = os_str_bytes(target.as_os_str())?;
6253 fingerprint.update(b"symlink\0");
6254 fingerprint.update(&bytes);
6255 let content = Sha256::digest(&bytes).into();
6256 return Ok(Some(WorktreeFile {
6257 mode: "120000".to_string(),
6258 #[cfg(unix)]
6259 permissions: 0,
6260 raw_oid: git_blob_oid(oid_len, &bytes)?,
6261 fingerprint: fingerprint.finalize().into(),
6262 content,
6263 }));
6264 }
6265 if !metadata.is_file() {
6266 bail!("tracked path {} is not a file or symlink", path.display());
6267 }
6268
6269 let mut options = OpenOptions::new();
6270 options.read(true);
6271 #[cfg(unix)]
6272 {
6273 use std::os::unix::fs::OpenOptionsExt;
6274 options.custom_flags(libc::O_NOFOLLOW);
6275 }
6276 let mut file = options
6277 .open(path)
6278 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6279 let before = file
6280 .metadata()
6281 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6282 let mode = tracked_file_mode(&before);
6283 #[cfg(unix)]
6284 let permissions = {
6285 use std::os::unix::fs::MetadataExt;
6286 before.mode() & 0o7777
6287 };
6288 fingerprint.update(b"file\0");
6289 fingerprint.update(mode.as_bytes());
6290 #[cfg(unix)]
6291 fingerprint.update(permissions.to_le_bytes());
6292 fingerprint.update(before.len().to_le_bytes());
6293 let mut content = Sha256::new();
6294 let header = format!("blob {}\0", before.len());
6295 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6296 let mut buf = [0u8; 64 * 1024];
6297 loop {
6298 let read = file
6299 .read(&mut buf)
6300 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6301 if read == 0 {
6302 break;
6303 }
6304 fingerprint.update(&buf[..read]);
6305 content.update(&buf[..read]);
6306 object.update(&buf[..read]);
6307 }
6308 let after = file
6309 .metadata()
6310 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6311 if before.len() != after.len()
6312 || before.modified().ok() != after.modified().ok()
6313 || before.permissions() != after.permissions()
6314 {
6315 bail!(
6316 "tracked file {} changed while it was being inspected",
6317 path.display()
6318 );
6319 }
6320 let current = std::fs::symlink_metadata(path)
6321 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6322 if !same_file(&after, ¤t) {
6323 bail!(
6324 "tracked file {} was replaced while it was being inspected",
6325 path.display()
6326 );
6327 }
6328 Ok(Some(WorktreeFile {
6329 mode,
6330 #[cfg(unix)]
6331 permissions,
6332 raw_oid: object.finish(),
6333 fingerprint: fingerprint.finalize().into(),
6334 content: content.finalize().into(),
6335 }))
6336}
6337
6338fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
6339 let metadata = std::fs::symlink_metadata(path)
6340 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6341 let mut digest = Sha256::new();
6342 if metadata.file_type().is_symlink() {
6343 digest.update(b"symlink\0");
6344 let target = std::fs::read_link(path)
6345 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
6346 digest.update(os_str_bytes(target.as_os_str())?);
6347 return Ok(digest.finalize().into());
6348 }
6349 if !metadata.is_file() {
6350 bail!("attribute path {} is not a file or symlink", path.display());
6351 }
6352 let mut options = OpenOptions::new();
6353 options.read(true);
6354 #[cfg(unix)]
6355 {
6356 use std::os::unix::fs::OpenOptionsExt;
6357 options.custom_flags(libc::O_NOFOLLOW);
6358 }
6359 let mut file = options
6360 .open(path)
6361 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6362 let before = file
6363 .metadata()
6364 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6365 digest.update(b"file\0");
6366 let mut buf = [0u8; 64 * 1024];
6367 loop {
6368 let read = file
6369 .read(&mut buf)
6370 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6371 if read == 0 {
6372 break;
6373 }
6374 digest.update(&buf[..read]);
6375 }
6376 let after = file
6377 .metadata()
6378 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6379 let current = std::fs::symlink_metadata(path)
6380 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6381 if before.len() != after.len()
6382 || before.modified().ok() != after.modified().ok()
6383 || !same_file(&after, ¤t)
6384 {
6385 bail!(
6386 "attribute file {} changed while it was being inspected",
6387 path.display()
6388 );
6389 }
6390 Ok(digest.finalize().into())
6391}
6392
6393enum ObjectHasher {
6394 Sha1(Sha1),
6395 Sha256(Sha256),
6396}
6397
6398impl ObjectHasher {
6399 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
6400 let mut hasher = match oid_len {
6401 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
6402 64 => Self::Sha256(Sha256::new()),
6403 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
6404 };
6405 hasher.update(header);
6406 Ok(hasher)
6407 }
6408
6409 fn update(&mut self, bytes: &[u8]) {
6410 match self {
6411 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
6412 Self::Sha256(hasher) => hasher.update(bytes),
6413 }
6414 }
6415
6416 fn finish(self) -> String {
6417 let bytes = match self {
6418 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
6419 Self::Sha256(hasher) => hasher.finalize().to_vec(),
6420 };
6421 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
6422 }
6423}
6424
6425fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
6426 let header = format!("blob {}\0", bytes.len());
6427 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
6428 hasher.update(bytes);
6429 Ok(hasher.finish())
6430}
6431
6432fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
6433 let mut first = open_regular_file(path)?;
6434 let first_before = first
6435 .metadata()
6436 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6437 let mut raw_len = 0u64;
6438 let mut crlf_pairs = 0u64;
6439 let mut previous_was_cr = false;
6440 let mut every_lf_was_crlf = true;
6441 let mut buf = [0u8; 64 * 1024];
6442 loop {
6443 let read = first
6444 .read(&mut buf)
6445 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6446 if read == 0 {
6447 break;
6448 }
6449 raw_len = raw_len
6450 .checked_add(read as u64)
6451 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
6452 for byte in &buf[..read] {
6453 if *byte == b'\n' {
6454 if previous_was_cr {
6455 crlf_pairs += 1;
6456 } else {
6457 every_lf_was_crlf = false;
6458 }
6459 }
6460 previous_was_cr = *byte == b'\r';
6461 }
6462 }
6463 let first_after = first
6464 .metadata()
6465 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6466 let current = std::fs::symlink_metadata(path)
6467 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6468 if raw_len != first_before.len()
6469 || !stable_file_metadata(&first_before, &first_after)
6470 || !stable_file_metadata(&first_after, ¤t)
6471 {
6472 bail!(
6473 "tracked file {} changed while line endings were inspected",
6474 path.display()
6475 );
6476 }
6477
6478 let normalized_len = raw_len
6479 .checked_sub(crlf_pairs)
6480 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
6481 let header = format!("blob {normalized_len}\0");
6482 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6483 let mut second = open_regular_file(path)?;
6484 let second_before = second
6485 .metadata()
6486 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6487 if !stable_file_metadata(&first_after, &second_before) {
6488 bail!(
6489 "tracked file {} changed between line-ending checks",
6490 path.display()
6491 );
6492 }
6493 let mut pending_cr = false;
6494 loop {
6495 let read = second
6496 .read(&mut buf)
6497 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6498 if read == 0 {
6499 break;
6500 }
6501 for byte in &buf[..read] {
6502 if pending_cr {
6503 if *byte == b'\n' {
6504 object.update(b"\n");
6505 pending_cr = false;
6506 continue;
6507 }
6508 object.update(b"\r");
6509 pending_cr = false;
6510 }
6511 if *byte == b'\r' {
6512 pending_cr = true;
6513 } else {
6514 object.update(std::slice::from_ref(byte));
6515 }
6516 }
6517 }
6518 if pending_cr {
6519 object.update(b"\r");
6520 }
6521 let second_after = second
6522 .metadata()
6523 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6524 let current = std::fs::symlink_metadata(path)
6525 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6526 if !stable_file_metadata(&second_before, &second_after)
6527 || !stable_file_metadata(&second_after, ¤t)
6528 {
6529 bail!(
6530 "tracked file {} changed while line endings were hashed",
6531 path.display()
6532 );
6533 }
6534 Ok((object.finish(), every_lf_was_crlf))
6535}
6536
6537fn open_regular_file(path: &Path) -> Result<std::fs::File> {
6538 let mut options = OpenOptions::new();
6539 options.read(true);
6540 #[cfg(unix)]
6541 {
6542 use std::os::unix::fs::OpenOptionsExt;
6543 options.custom_flags(libc::O_NOFOLLOW);
6544 }
6545 let file = options
6546 .open(path)
6547 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6548 let metadata = file
6549 .metadata()
6550 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6551 if !metadata.is_file() {
6552 bail!("tracked path {} is not a regular file", path.display());
6553 }
6554 Ok(file)
6555}
6556
6557fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6558 if !same_file(left, right)
6559 || left.len() != right.len()
6560 || left.modified().ok() != right.modified().ok()
6561 || left.permissions() != right.permissions()
6562 {
6563 return false;
6564 }
6565 #[cfg(unix)]
6566 {
6567 use std::os::unix::fs::MetadataExt;
6568 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
6569 }
6570 #[cfg(not(unix))]
6571 {
6572 left.created().ok() == right.created().ok()
6573 }
6574}
6575
6576const ATTRIBUTE_BATCH: usize = 1000;
6583
6584fn check_attributes(
6585 cwd: &Path,
6586 paths: impl IntoIterator<Item = PathBuf>,
6587) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6588 let paths = paths.into_iter().collect::<BTreeSet<_>>();
6589 if paths.is_empty() {
6590 return Ok(BTreeMap::new());
6591 }
6592 let ordered = paths.into_iter().collect::<Vec<_>>();
6593 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6594 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
6595 values.extend(check_attribute_batch(cwd, batch)?);
6596 }
6597 Ok(values)
6598}
6599
6600fn check_attribute_batch(
6601 cwd: &Path,
6602 batch: &[PathBuf],
6603) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6604 const NAMES: [&str; 6] = [
6605 "filter",
6606 "working-tree-encoding",
6607 "ident",
6608 "text",
6609 "eol",
6610 "crlf",
6611 ];
6612 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
6613 let mut input = String::new();
6614 for path in &paths {
6615 let path = path.to_str().ok_or_else(|| {
6616 spar_err!(
6617 "cannot inspect attributes for a non-UTF-8 path in {}",
6618 cwd.display()
6619 )
6620 })?;
6621 input.push_str(path);
6622 input.push('\0');
6623 }
6624 let argv = git_without_automation_argv(&[
6625 "check-attr",
6626 "-z",
6627 "--cached",
6628 "--stdin",
6629 "filter",
6630 "working-tree-encoding",
6631 "ident",
6632 "text",
6633 "eol",
6634 "crlf",
6635 ]);
6636 let output = proc::run_bytes(
6637 &argv,
6638 &ExecOpts::new()
6639 .cwd(cwd)
6640 .timeout_secs(30)
6641 .stdin(input)
6642 .stop_descendants(true),
6643 )?;
6644 if !output.is_empty() && !output.ends_with(&[0]) {
6645 bail!(
6646 "git returned an unterminated attribute result for {}",
6647 cwd.display()
6648 );
6649 }
6650 let fields = output
6651 .split(|byte| *byte == 0)
6652 .filter(|field| !field.is_empty())
6653 .collect::<Vec<_>>();
6654 if fields.len() != paths.len() * NAMES.len() * 3 {
6655 bail!(
6656 "git returned an unexpected attribute result for {}",
6657 cwd.display()
6658 );
6659 }
6660 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6661 for record in fields.chunks_exact(3) {
6662 let path = safe_git_path(record[0], "attribute")?;
6663 if !paths.contains(&path) {
6664 bail!(
6665 "git returned attributes for the wrong path in {}",
6666 cwd.display()
6667 );
6668 }
6669 let name = std::str::from_utf8(record[1])
6670 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
6671 let value = std::str::from_utf8(record[2])
6672 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
6673 values
6674 .entry(path)
6675 .or_default()
6676 .insert(name.to_string(), value.to_string());
6677 }
6678 if paths.iter().any(|path| {
6679 values
6680 .get(path)
6681 .is_none_or(|attributes| attributes.len() != NAMES.len())
6682 }) {
6683 bail!(
6684 "git omitted an attribute result for a tracked path in {}",
6685 cwd.display()
6686 );
6687 }
6688 Ok(values)
6689}
6690
6691fn attribute_is_active(value: Option<&String>) -> bool {
6692 !matches!(
6693 value.map(String::as_str),
6694 None | Some("unspecified") | Some("unset")
6695 )
6696}
6697
6698fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
6699 attribute_is_active(values.get("filter"))
6700 || attribute_is_active(values.get("working-tree-encoding"))
6701}
6702
6703struct CheckoutConfig {
6712 autocrlf: Option<String>,
6713 eol: Option<String>,
6714 symlinks: Option<bool>,
6715}
6716
6717impl CheckoutConfig {
6718 fn read(cwd: &Path) -> Result<Self> {
6719 Ok(Self {
6720 autocrlf: git_config_value(cwd, "core.autocrlf")?,
6721 eol: git_config_value(cwd, "core.eol")?,
6722 symlinks: git_config_bool(cwd, "core.symlinks")?,
6723 })
6724 }
6725
6726 fn autocrlf_is_true(&self) -> bool {
6727 self.autocrlf.as_ref().is_some_and(|value| {
6728 matches!(
6729 value.to_ascii_lowercase().as_str(),
6730 "true" | "yes" | "on" | "1"
6731 )
6732 })
6733 }
6734
6735 fn eol_is(&self, wanted: &str) -> bool {
6736 self.eol
6737 .as_ref()
6738 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6739 }
6740}
6741
6742fn path_has_ambiguous_transform(
6743 config: &CheckoutConfig,
6744 values: &BTreeMap<String, String>,
6745) -> Result<bool> {
6746 if path_has_external_transform(values)
6747 || attribute_is_active(values.get("ident"))
6748 || attribute_is_active(values.get("crlf"))
6749 {
6750 return Ok(true);
6751 }
6752 let text = values.get("text").map(String::as_str);
6753 let eol = values.get("eol").map(String::as_str);
6754 if text == Some("auto") {
6755 return Ok(true);
6756 }
6757 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6758 || !matches!(
6759 eol,
6760 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6761 )
6762 {
6763 return Ok(true);
6764 }
6765 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6766 return Ok(config.autocrlf_is_true());
6767 }
6768 Ok(false)
6769}
6770
6771fn allows_expected_crlf(
6772 config: &CheckoutConfig,
6773 values: &BTreeMap<String, String>,
6774) -> Result<bool> {
6775 if path_has_external_transform(values)
6776 || attribute_is_active(values.get("ident"))
6777 || attribute_is_active(values.get("crlf"))
6778 {
6779 return Ok(false);
6780 }
6781 let text = values.get("text").map(String::as_str);
6782 let eol = values.get("eol").map(String::as_str);
6783 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6784 return Ok(false);
6785 }
6786 if eol == Some("crlf") {
6787 return Ok(true);
6788 }
6789 if text != Some("set") {
6790 return Ok(false);
6791 }
6792 if let Some(autocrlf) = config.autocrlf.as_deref() {
6793 match autocrlf.to_ascii_lowercase().as_str() {
6794 "true" | "yes" | "on" | "1" => return Ok(true),
6795 "input" => return Ok(false),
6796 _ => {}
6797 }
6798 }
6799 if config.eol_is("crlf") {
6800 return Ok(true);
6801 }
6802 #[cfg(windows)]
6803 if config.eol.is_none() || config.eol_is("native") {
6804 return Ok(true);
6805 }
6806 Ok(false)
6807}
6808
6809fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6810 let argv = git_without_automation_argv(&["config", "--get", key]);
6811 let output = proc::exec(
6812 &argv,
6813 &ExecOpts::new()
6814 .cwd(cwd)
6815 .timeout_secs(30)
6816 .check(false)
6817 .stop_descendants(true),
6818 )?;
6819 match output.code {
6820 0 => Ok(Some(output.stdout.trim().to_string())),
6821 1 => Ok(None),
6822 _ => bail!(
6823 "could not read Git configuration in {}: {}",
6824 cwd.display(),
6825 output.stderr.trim()
6826 ),
6827 }
6828}
6829
6830fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6831 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6832 let output = proc::exec(
6833 &argv,
6834 &ExecOpts::new()
6835 .cwd(cwd)
6836 .timeout_secs(30)
6837 .check(false)
6838 .stop_descendants(true),
6839 )?;
6840 match output.code {
6841 0 if output.stdout.trim() == "true" => Ok(Some(true)),
6842 0 if output.stdout.trim() == "false" => Ok(Some(false)),
6843 0 => bail!(
6844 "git returned an invalid boolean for {key} in {}",
6845 cwd.display()
6846 ),
6847 1 => Ok(None),
6848 _ => bail!(
6849 "could not read Git configuration in {}: {}",
6850 cwd.display(),
6851 output.stderr.trim()
6852 ),
6853 }
6854}
6855
6856#[cfg(unix)]
6857fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6858 use std::os::unix::fs::PermissionsExt;
6859 if metadata.permissions().mode() & 0o111 == 0 {
6860 "100644".to_string()
6861 } else {
6862 "100755".to_string()
6863 }
6864}
6865
6866#[cfg(not(unix))]
6867fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6868 "100644".to_string()
6869}
6870
6871fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6872 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6873 if !listed.is_empty() && !listed.ends_with(&[0]) {
6874 bail!(
6875 "git returned an unterminated tree listing for {}",
6876 cwd.display()
6877 );
6878 }
6879 let mut entries = Vec::new();
6880 for record in listed
6881 .split(|byte| *byte == 0)
6882 .filter(|record| !record.is_empty())
6883 {
6884 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6885 bail!("git returned a malformed tree record for {}", cwd.display());
6886 };
6887 let fields = record[..tab]
6888 .split(|byte| *byte == b' ')
6889 .collect::<Vec<_>>();
6890 if fields.len() != 3 {
6891 bail!("git returned a malformed tree header for {}", cwd.display());
6892 }
6893 let mode = std::str::from_utf8(fields[0])
6894 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6895 .to_string();
6896 let oid = std::str::from_utf8(fields[2])
6897 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6898 .to_string();
6899 entries.push(IndexEntry {
6900 path: safe_git_path(&record[tab + 1..], "tree")?,
6901 mode,
6902 oid,
6903 });
6904 }
6905 Ok(entries)
6906}
6907
6908fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6909 Ok(tree_entries(cwd, "HEAD")?
6910 .into_iter()
6911 .filter(|entry| entry.mode == "160000")
6912 .map(|entry| (entry.path, entry.oid))
6913 .collect())
6914}
6915
6916fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6917 let head = head_gitlinks(cwd)?;
6918 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6919 .into_iter()
6920 .map(|link| (link.path, link.oid))
6921 .collect();
6922 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6923 paths.extend(index.keys().cloned());
6924 Ok(paths
6925 .into_iter()
6926 .filter(|path| head.get(path) != index.get(path))
6927 .collect())
6928}
6929
6930fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6931 let path = parent.join(relative);
6932 let metadata = match std::fs::symlink_metadata(&path) {
6933 Ok(metadata) => metadata,
6934 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6935 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6936 };
6937 if !metadata.is_dir() {
6938 bail!("the gitlink at {} is not a directory", path.display());
6939 }
6940 let canonical = std::fs::canonicalize(&path)
6941 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6942 if canonical != path {
6943 bail!(
6944 "the gitlink at {} resolves through a symlink",
6945 path.display()
6946 );
6947 }
6948 if !path.join(".git").exists() {
6949 let empty = std::fs::read_dir(&path)
6950 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6951 .next()
6952 .is_none();
6953 if empty {
6954 return Ok(None);
6955 }
6956 bail!(
6957 "the uninitialized gitlink at {} contains local files",
6958 path.display()
6959 );
6960 }
6961 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6962 if inside.trim() != "true" {
6963 bail!("the gitlink at {} is not a worktree", path.display());
6964 }
6965 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6966 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6967 spar_err!(
6968 "could not resolve the gitlink top level at {}: {e}",
6969 path.display()
6970 )
6971 })?;
6972 if top != canonical {
6973 bail!(
6974 "the gitlink at {} belongs to a different worktree",
6975 path.display()
6976 );
6977 }
6978 Ok(Some(canonical))
6979}
6980
6981fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6995 let root = std::fs::canonicalize(cwd)
6996 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6997 let mut allowed = BTreeSet::from([root.join(".git")]);
6998 let mut checkouts = BTreeSet::new();
6999 let mut repositories = vec![root.clone()];
7000 let mut visited = BTreeSet::new();
7001 while let Some(repository) = repositories.pop() {
7002 let canonical = std::fs::canonicalize(&repository)
7003 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7004 if !visited.insert(canonical.clone()) {
7005 bail!("submodule recursion revisited {}", canonical.display());
7006 }
7007 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
7008 let (path, nested) = untracked_record(&raw, "untracked")?;
7009 if nested {
7010 checkouts.insert(canonical.join(path));
7011 }
7012 }
7013 for link in gitlinks(&canonical)? {
7014 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
7015 continue;
7016 };
7017 allowed.insert(submodule.join(".git"));
7018 repositories.push(submodule);
7019 }
7020 }
7021
7022 let scan_root = root.clone();
7026 let mut directories = vec![root];
7027 while let Some(directory) = directories.pop() {
7028 let entries = match std::fs::read_dir(&directory) {
7029 Ok(entries) => entries,
7030 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7031 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7032 };
7033 for entry in entries {
7034 let entry = match entry {
7035 Ok(entry) => entry,
7036 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7037 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7038 };
7039 let path = entry.path();
7040 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
7041 continue;
7042 }
7043 if entry.file_name() == OsStr::new(".git") {
7044 if !allowed.contains(&path) {
7045 return Ok(Some(path));
7046 }
7047 continue;
7048 }
7049 let kind = match entry.file_type() {
7050 Ok(kind) => kind,
7051 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7052 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7053 };
7054 if kind.is_dir() && !checkouts.contains(&path) {
7055 directories.push(path);
7056 }
7057 }
7058 }
7059 Ok(None)
7060}
7061
7062pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
7063 if let Some(path) = unexpected_nested_git_entry(cwd)? {
7064 bail!(
7065 "the worktree contains a Git entry at {} that Git does not report as a nested \
7066 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
7067 prove they are unchanged.",
7068 path.display()
7069 );
7070 }
7071 let root = std::fs::canonicalize(cwd)
7072 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7073 let mut repositories = BTreeMap::new();
7074 let mut visited = BTreeSet::new();
7075 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7076 Ok(GitState { repositories })
7077}
7078
7079fn collect_git_state(
7080 repository: &Path,
7081 prefix: &Path,
7082 visited: &mut BTreeSet<PathBuf>,
7083 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7084) -> Result<()> {
7085 let canonical = std::fs::canonicalize(repository)
7086 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7087 if !visited.insert(canonical.clone()) {
7088 bail!("submodule recursion revisited {}", canonical.display());
7089 }
7090 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7091 let head = head.trim().to_string();
7092 if head.is_empty() {
7093 bail!("git returned an empty head for {}", repository.display());
7094 }
7095 let unsafe_index_flags = unsafe_index_flags(repository)?;
7096 let tracked = tracked_entries(repository)?;
7097 let gitlinks = gitlinks(repository)?;
7098 if repositories
7099 .insert(
7100 prefix.to_path_buf(),
7101 RepositoryState {
7102 head,
7103 unsafe_index_flags,
7104 tracked,
7105 gitlinks: gitlinks
7106 .iter()
7107 .map(|link| (link.path.clone(), link.oid.clone()))
7108 .collect(),
7109 },
7110 )
7111 .is_some()
7112 {
7113 bail!("Git state contains duplicate repository path {:?}", prefix);
7114 }
7115
7116 for link in gitlinks {
7117 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7118 continue;
7119 };
7120 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7121 }
7122 Ok(())
7123}
7124
7125fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7126 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7127 if !listed.is_empty() && !listed.ends_with(&[0]) {
7128 bail!(
7129 "git returned an unterminated index-flag listing for {}",
7130 cwd.display()
7131 );
7132 }
7133 let mut unsafe_records = Vec::new();
7134 for record in listed
7135 .split(|byte| *byte == 0)
7136 .filter(|record| !record.is_empty())
7137 {
7138 if record.len() < 3 || record[1] != b' ' {
7139 bail!(
7140 "git returned a malformed index-flag record for {}",
7141 cwd.display()
7142 );
7143 }
7144 if record[0] != b'H' {
7145 unsafe_records.extend_from_slice(record);
7146 unsafe_records.push(0);
7147 }
7148 }
7149 Ok(unsafe_records)
7150}
7151
7152pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7153 safe_git_state(cwd).map(|_| ())
7154}
7155
7156pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7157 let state = git_state(cwd)?;
7158 if let Some((path, _repository)) = state
7159 .repositories
7160 .iter()
7161 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7162 {
7163 let label = if path.as_os_str().is_empty() {
7164 cwd.to_path_buf()
7165 } else {
7166 cwd.join(path)
7167 };
7168 bail!(
7169 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7170 SPAR cannot prove the working files are unchanged, so it was kept.",
7171 label.display()
7172 );
7173 }
7174 Ok(state)
7175}
7176
7177fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7178 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7179}
7180
7181fn repository_has_recoverable_work_with(
7182 cwd: &Path,
7183 include_ignored: bool,
7184 disposable: Disposable,
7185) -> Result<bool> {
7186 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7187 return Ok(true);
7188 }
7189 let mut visited = BTreeSet::new();
7190 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7191}
7192
7193fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7194 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7195 let git_dir = PathBuf::from(git_dir.trim());
7196 let git_dir = if git_dir.is_absolute() {
7197 git_dir
7198 } else {
7199 cwd.join(git_dir)
7200 };
7201 let git_dir = std::fs::canonicalize(&git_dir)
7202 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
7203 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
7204 Ok(_) => return Ok(true),
7205 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7206 Err(error) => {
7207 return Err(spar_err!(
7208 "could not inspect per-worktree configuration in {}: {error}",
7209 git_dir.display()
7210 ))
7211 }
7212 }
7213
7214 let orig_head = git_dir.join("ORIG_HEAD");
7215 match std::fs::symlink_metadata(&orig_head) {
7216 Ok(metadata) if metadata.is_file() => {
7217 let oid = std::fs::read_to_string(&orig_head)
7218 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
7219 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
7220 return Ok(true);
7221 };
7222 if !commit_has_shared_ref(cwd, &commit)? {
7223 return Ok(true);
7224 }
7225 }
7226 Ok(_) => return Ok(true),
7227 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7228 Err(error) => {
7229 return Err(spar_err!(
7230 "could not inspect {}: {error}",
7231 orig_head.display()
7232 ))
7233 }
7234 }
7235
7236 let edit_message = git_dir.join("COMMIT_EDITMSG");
7237 match std::fs::symlink_metadata(&edit_message) {
7238 Ok(metadata) if metadata.is_file() => {
7239 let draft = std::fs::read(&edit_message)
7240 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
7241 if draft != head_commit_message(cwd)? {
7242 return Ok(true);
7243 }
7244 }
7245 Ok(_) => return Ok(true),
7246 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7247 Err(error) => {
7248 return Err(spar_err!(
7249 "could not inspect {}: {error}",
7250 edit_message.display()
7251 ))
7252 }
7253 }
7254
7255 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
7256 return Ok(true);
7257 }
7258
7259 let local_refs = run_git_bytes(
7260 cwd,
7261 &[
7262 "for-each-ref",
7263 "--format=%(refname)",
7264 "refs/worktree",
7265 "refs/bisect",
7266 "refs/rewritten",
7267 ],
7268 )?;
7269 if !local_refs.is_empty() {
7270 return Ok(true);
7271 }
7272
7273 for entry in std::fs::read_dir(&git_dir)
7277 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
7278 {
7279 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
7280 let known = matches!(
7281 entry.file_name().to_str(),
7282 Some(
7283 "HEAD"
7284 | "ORIG_HEAD"
7285 | "COMMIT_EDITMSG"
7286 | "FETCH_HEAD"
7287 | "commondir"
7288 | "gitdir"
7289 | "index"
7290 | "logs"
7291 | "refs"
7292 )
7293 );
7294 if !known {
7295 return Ok(true);
7296 }
7297 }
7298
7299 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7300 if !commit_has_shared_ref(cwd, head.trim())? {
7301 return Ok(true);
7302 }
7303 Ok(false)
7304}
7305
7306fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
7307 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
7308 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
7309 bail!(
7310 "git returned a commit without a message separator in {}",
7311 cwd.display()
7312 );
7313 };
7314 Ok(commit[split + 2..].to_vec())
7315}
7316
7317fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
7318 let metadata = match std::fs::symlink_metadata(logs) {
7319 Ok(metadata) => metadata,
7320 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
7321 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
7322 };
7323 if !metadata.is_dir() {
7324 return Ok(true);
7325 }
7326 let mut files = Vec::new();
7327 let mut directories = vec![logs.to_path_buf()];
7328 while let Some(directory) = directories.pop() {
7329 for entry in std::fs::read_dir(&directory)
7330 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
7331 {
7332 let entry =
7333 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
7334 let path = entry.path();
7335 let kind = entry
7336 .file_type()
7337 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
7338 if kind.is_dir() {
7339 directories.push(path);
7340 } else if kind.is_file() {
7341 files.push(path);
7342 } else {
7343 return Ok(true);
7344 }
7345 }
7346 }
7347
7348 let mut commits = BTreeSet::new();
7349 for path in files {
7350 if !collect_reflog_commits(cwd, &path, &mut commits)? {
7351 return Ok(true);
7352 }
7353 }
7354 for commit in commits {
7355 if !commit_has_shared_ref(cwd, &commit)? {
7356 return Ok(true);
7357 }
7358 }
7359 Ok(false)
7360}
7361
7362fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
7366 let common = common_git_dir(cwd)?;
7367 let reflog = common.join("logs").join(refname);
7368 let metadata = match std::fs::symlink_metadata(&reflog) {
7369 Ok(metadata) => metadata,
7370 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
7371 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
7372 };
7373 if !metadata.is_file() {
7374 return Ok(false);
7375 }
7376 let mut commits = BTreeSet::new();
7377 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
7378 return Ok(false);
7379 }
7380 for commit in commits {
7381 if is_ancestor(cwd, &commit, durable_tip)?
7382 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
7383 {
7384 continue;
7385 }
7386 return Ok(false);
7387 }
7388 Ok(true)
7389}
7390
7391fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
7392 let file = std::fs::File::open(path)
7393 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7394 for line in std::io::BufReader::new(file).lines() {
7395 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7396 let mut fields = line.splitn(3, ' ');
7397 let Some(old) = fields.next() else {
7398 return Ok(false);
7399 };
7400 let Some(new) = fields.next() else {
7401 return Ok(false);
7402 };
7403 if fields.next().is_none() {
7404 return Ok(false);
7405 }
7406 for oid in [old, new] {
7407 if oid.bytes().all(|byte| byte == b'0') {
7408 continue;
7409 }
7410 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
7411 return Ok(false);
7412 };
7413 commits.insert(commit);
7414 }
7415 }
7416 Ok(true)
7417}
7418
7419fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
7420 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
7421 let path = PathBuf::from(raw.trim());
7422 let path = if path.is_absolute() {
7423 path
7424 } else {
7425 cwd.join(path)
7426 };
7427 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
7428}
7429
7430fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
7431 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
7432 let output = proc::exec(
7433 &argv,
7434 &ExecOpts::new()
7435 .cwd(cwd)
7436 .timeout_secs(30)
7437 .check(false)
7438 .stop_descendants(true),
7439 )?;
7440 match output.code {
7441 0 => Ok(true),
7442 1 => Ok(false),
7443 _ => bail!("{}", proc::failure_message(&argv, &output)),
7444 }
7445}
7446
7447fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
7448 let commit = format!("{oid}^{{commit}}");
7449 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
7450 let output = proc::exec(
7451 &argv,
7452 &ExecOpts::new()
7453 .cwd(cwd)
7454 .timeout_secs(30)
7455 .check(false)
7456 .stop_descendants(true),
7457 )?;
7458 if output.code != 0 {
7459 return Ok(None);
7460 }
7461 let oid = output.stdout.trim();
7462 if oid.is_empty() {
7463 return Ok(None);
7464 }
7465 Ok(Some(oid.to_string()))
7466}
7467
7468fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
7469 commit_has_shared_ref_except(cwd, oid, None)
7470}
7471
7472fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
7473 let contains = format!("--contains={oid}");
7474 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
7475 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
7476 !record.is_empty()
7477 && !record.starts_with(b"refs/worktree/")
7478 && !record.starts_with(b"refs/bisect/")
7479 && !record.starts_with(b"refs/rewritten/")
7480 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
7481 }))
7482}
7483
7484fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
7499 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
7500 for raw in &ordinary {
7501 let (path, _) = untracked_record(raw, "untracked")?;
7502 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
7503 continue;
7504 }
7505 return Ok(true);
7506 }
7507 let listed = untracked_listing(
7508 cwd,
7509 &[
7510 "ls-files",
7511 "--others",
7512 "--ignored",
7513 "--exclude-standard",
7514 "-z",
7515 ],
7516 )?;
7517 for raw in listed {
7518 let (path, nested) = untracked_record(&raw, "ignored")?;
7519 if nested {
7523 return Ok(true);
7524 }
7525 if disposable == Disposable::BuildOutput
7526 && !is_generated_artifact(&path)
7527 && !is_desktop_metadata(&path)
7528 {
7529 return Ok(true);
7530 }
7531 }
7532 Ok(false)
7533}
7534
7535fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
7536 let listed = run_git_bytes(cwd, args)?;
7537 if !listed.is_empty() && !listed.ends_with(&[0]) {
7538 bail!(
7539 "git returned an unterminated untracked-file list for {}",
7540 cwd.display()
7541 );
7542 }
7543 Ok(listed
7544 .split(|byte| *byte == 0)
7545 .filter(|raw| !raw.is_empty())
7546 .map(|raw| raw.to_vec())
7547 .collect())
7548}
7549
7550fn repository_has_recoverable_work_inner(
7551 cwd: &Path,
7552 include_ignored: bool,
7553 disposable: Disposable,
7554 visited: &mut BTreeSet<PathBuf>,
7555) -> Result<bool> {
7556 let canonical = std::fs::canonicalize(cwd)
7557 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7558 if !visited.insert(canonical.clone()) {
7559 bail!("submodule recursion revisited {}", canonical.display());
7560 }
7561 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
7562 return Ok(true);
7563 }
7564 if !unsafe_index_flags(cwd)?.is_empty() {
7565 return Ok(true);
7566 }
7567 if attributes_may_be_modified(cwd)? {
7568 return Ok(true);
7569 }
7570 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
7571 return Ok(true);
7572 }
7573 if include_ignored {
7574 let index = index_entries(cwd)?
7575 .into_iter()
7576 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7577 .collect::<BTreeMap<_, _>>();
7578 let head = tree_entries(cwd, "HEAD")?
7579 .into_iter()
7580 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7581 .collect::<BTreeMap<_, _>>();
7582 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
7583 return Ok(true);
7584 }
7585 let tracked = tracked_entries(cwd)?;
7586 let effective = check_attributes(cwd, tracked.keys().cloned())?;
7587 let config = CheckoutConfig::read(cwd)?;
7588 for (path, entry) in tracked {
7589 let Some(worktree) = entry.worktree else {
7590 return Ok(true);
7591 };
7592 let attributes = effective.get(&path).ok_or_else(|| {
7593 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
7594 })?;
7595 if path_has_ambiguous_transform(&config, attributes)? {
7596 return Ok(true);
7597 }
7598 let symlink_file = entry.index_mode == "120000"
7599 && worktree.mode == "100644"
7600 && worktree.raw_oid == entry.index_oid
7601 && config.symlinks == Some(false);
7602 if worktree.mode != entry.index_mode && !symlink_file {
7603 return Ok(true);
7604 }
7605 if entry.index_mode == "120000" {
7606 if worktree.raw_oid != entry.index_oid {
7607 return Ok(true);
7608 }
7609 continue;
7610 }
7611 #[cfg(unix)]
7612 {
7613 let expected = if entry.index_mode == "100755" {
7614 0o755
7615 } else {
7616 0o644
7617 };
7618 if worktree.permissions != expected {
7619 return Ok(true);
7620 }
7621 }
7622 if allows_expected_crlf(&config, attributes)? {
7623 let (normalized, every_lf_was_crlf) =
7624 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
7625 if !every_lf_was_crlf || normalized != entry.index_oid {
7626 return Ok(true);
7627 }
7628 } else if worktree.raw_oid != entry.index_oid {
7629 return Ok(true);
7630 }
7631 }
7632 } else {
7633 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
7634 if !run_git_bytes(cwd, &args)?.is_empty() {
7635 return Ok(true);
7636 }
7637 }
7638 for link in gitlinks(cwd)? {
7639 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
7640 continue;
7641 };
7642 if include_ignored {
7647 return Ok(true);
7648 }
7649 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7650 if head.trim() != link.oid {
7651 return Ok(true);
7652 }
7653 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
7654 {
7655 return Ok(true);
7656 }
7657 }
7658 Ok(false)
7659}
7660
7661pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
7662 repository_has_recoverable_work(cwd, false)
7663}
7664
7665fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
7666 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
7667 Ok(!run_git_bytes(cwd, &args)?.is_empty())
7668}
7669
7670#[cfg(unix)]
7671fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7672 use std::os::unix::ffi::OsStringExt;
7673 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
7674}
7675
7676#[cfg(not(unix))]
7677fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7678 String::from_utf8(raw.to_vec())
7679 .map(PathBuf::from)
7680 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
7681}
7682
7683fn vanished_untracked_file() -> UntrackedFile {
7699 UntrackedFile {
7700 kind: 0,
7701 len: 0,
7702 modified: None,
7703 created: None,
7704 readonly: false,
7705 symlink_target: None,
7706 #[cfg(unix)]
7707 device: 0,
7708 #[cfg(unix)]
7709 inode: 0,
7710 #[cfg(unix)]
7711 mode: 0,
7712 #[cfg(unix)]
7713 change_seconds: 0,
7714 #[cfg(unix)]
7715 change_nanoseconds: 0,
7716 }
7717}
7718
7719fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
7720 let metadata = match std::fs::symlink_metadata(path) {
7721 Ok(metadata) => metadata,
7722 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7723 Err(e) => {
7724 return Err(spar_err!(
7725 "could not inspect the nested repository at {}: {e}",
7726 path.display()
7727 ))
7728 }
7729 };
7730 if !metadata.is_dir() {
7731 bail!(
7732 "git reported {} as a nested repository, but it is not a directory",
7733 path.display()
7734 );
7735 }
7736 if std::fs::symlink_metadata(path.join(".git")).is_err() {
7737 bail!(
7738 "git reported {} as a nested repository, but it has no Git entry",
7739 path.display()
7740 );
7741 }
7742 #[cfg(unix)]
7743 {
7744 use std::os::unix::fs::MetadataExt;
7745 Ok(UntrackedFile {
7746 kind: 3,
7747 len: 0,
7748 modified: None,
7749 created: metadata.created().ok(),
7750 readonly: metadata.permissions().readonly(),
7751 symlink_target: None,
7752 device: metadata.dev(),
7753 inode: metadata.ino(),
7754 mode: metadata.mode(),
7755 change_seconds: 0,
7756 change_nanoseconds: 0,
7757 })
7758 }
7759 #[cfg(not(unix))]
7760 {
7761 Ok(UntrackedFile {
7762 kind: 3,
7763 len: 0,
7764 modified: None,
7765 created: metadata.created().ok(),
7766 readonly: metadata.permissions().readonly(),
7767 symlink_target: None,
7768 })
7769 }
7770}
7771
7772fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7773 let metadata = match std::fs::symlink_metadata(path) {
7774 Ok(metadata) => metadata,
7775 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7776 Err(e) => {
7777 return Err(spar_err!(
7778 "could not inspect untracked file {}: {e}",
7779 path.display()
7780 ))
7781 }
7782 };
7783 let kind = if metadata.file_type().is_symlink() {
7784 2
7785 } else if metadata.is_file() {
7786 1
7787 } else {
7788 bail!(
7789 "untracked path {} is not a regular file or symlink",
7790 path.display()
7791 );
7792 };
7793 let symlink_target = if kind == 2 {
7794 match std::fs::read_link(path) {
7795 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7796 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7797 return Ok(vanished_untracked_file())
7798 }
7799 Err(e) => {
7800 return Err(spar_err!(
7801 "could not read untracked symlink {}: {e}",
7802 path.display()
7803 ))
7804 }
7805 }
7806 } else {
7807 None
7808 };
7809 #[cfg(unix)]
7810 {
7811 use std::os::unix::fs::MetadataExt;
7812 Ok(UntrackedFile {
7813 kind,
7814 len: metadata.len(),
7815 modified: metadata.modified().ok(),
7816 created: metadata.created().ok(),
7817 readonly: metadata.permissions().readonly(),
7818 symlink_target,
7819 device: metadata.dev(),
7820 inode: metadata.ino(),
7821 mode: metadata.mode(),
7822 change_seconds: metadata.ctime(),
7823 change_nanoseconds: metadata.ctime_nsec(),
7824 })
7825 }
7826 #[cfg(not(unix))]
7827 {
7828 Ok(UntrackedFile {
7829 kind,
7830 len: metadata.len(),
7831 modified: metadata.modified().ok(),
7832 created: metadata.created().ok(),
7833 readonly: metadata.permissions().readonly(),
7834 symlink_target,
7835 })
7836 }
7837}
7838
7839#[cfg(unix)]
7840fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7841 use std::os::unix::ffi::OsStrExt;
7842 Ok(value.as_bytes().to_vec())
7843}
7844
7845#[cfg(not(unix))]
7846fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7847 value
7848 .to_str()
7849 .map(|value| value.as_bytes().to_vec())
7850 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7851}
7852
7853#[cfg(unix)]
7854fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7855 use std::os::unix::fs::MetadataExt;
7856 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7857}
7858
7859#[cfg(not(unix))]
7860fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7861 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7862}
7863
7864#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7865pub struct BranchRecord {
7866 pub kind: String,
7867 pub number: i64,
7868}
7869
7870pub fn review_ref(number: i64) -> String {
7873 format!("refs/spar/pr-{number}")
7874}
7875
7876pub fn is_finished(state: &str) -> bool {
7877 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7878}
7879
7880pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7887 if let Some(parent) = path.parent() {
7888 std::fs::create_dir_all(parent)
7889 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7890 }
7891 let tmp = path.with_extension(format!(
7894 "{}.tmp",
7895 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7896 ));
7897 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7898 std::fs::rename(&tmp, path)
7899 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7900 Ok(())
7901}
7902
7903pub fn has_heading(text: &str, title: &str) -> bool {
7910 let wanted = title.trim().to_lowercase();
7911 if wanted.is_empty() {
7912 return false;
7913 }
7914 text.lines()
7915 .filter_map(|line| line.trim().strip_prefix("## "))
7916 .any(|heading| heading.trim().to_lowercase() == wanted)
7917}
7918
7919pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7922 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7923}
7924
7925#[derive(Deserialize)]
7927#[serde(rename_all = "camelCase")]
7928pub struct LinkedPrRow {
7929 number: i64,
7930 #[serde(default)]
7931 url: String,
7932 #[serde(default)]
7933 title: String,
7934 #[serde(default)]
7935 closing_issues_references: Vec<IssueRef>,
7936}
7937
7938pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7945 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7946}
7947
7948pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7950 rows.iter()
7951 .find(|row| {
7952 row.closing_issues_references
7953 .iter()
7954 .any(|linked| linked.number == issue)
7955 })
7956 .map(|row| PrRef {
7957 number: row.number,
7958 url: row.url.clone(),
7959 title: row.title.clone(),
7960 })
7961}
7962
7963pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7965 pick_linked_pr(&linked_pr_rows(json)?, issue)
7966}
7967
7968pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7975 if text.trim().is_empty() {
7976 return Err(spar_err!("GitHub returned no comment data"));
7977 }
7978 let mut out = Vec::new();
7979 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7980 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7981 Value::Array(items) => out.extend(items),
7982 _ => return Err(spar_err!("unexpected non-array comment page")),
7983 }
7984 }
7985 Ok(out)
7986}
7987
7988pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7989 let mut out = Vec::new();
7990 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7991 match value {
7992 Ok(Value::Array(items)) => out.extend(items),
7993 Ok(other) => out.push(other),
7994 Err(_) => break,
7995 }
7996 }
7997 out
7998}
7999
8000pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
8008 body.trim_start().starts_with(STATE_MARKER)
8009 && !viewer.trim().is_empty()
8010 && author.trim().eq_ignore_ascii_case(viewer.trim())
8011 && parse_state_comment(body).is_some()
8012}
8013
8014pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
8017 let marker = body.find(STATE_MARKER)?;
8018 let start = body[marker..].find('{')? + marker;
8019 let end = body.rfind('}')?;
8020 if end <= start {
8021 return None;
8022 }
8023 match serde_json::from_str(&body[start..=end]) {
8024 Ok(state) => Some(state),
8025 Err(_) => {
8026 logdim!("found a spar state comment but could not parse it");
8027 None
8028 }
8029 }
8030}
8031
8032fn choose_state_for_head(
8033 candidates: Vec<PersistedState>,
8034 actual_head: &str,
8035) -> Option<PersistedState> {
8036 let matching: Vec<PersistedState> = candidates
8037 .iter()
8038 .filter(|state| state.pr_head == actual_head)
8039 .cloned()
8040 .collect();
8041 if !matching.is_empty() {
8042 return newest_state(matching);
8043 }
8044 newest_state(candidates)
8045}
8046
8047fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
8048 candidates.into_iter().reduce(|best, candidate| {
8049 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
8050 candidate
8051 } else {
8052 best
8057 }
8058 })
8059}
8060
8061pub fn self_binary() -> Result<PathBuf> {
8067 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
8068 let path = PathBuf::from(path);
8069 if proc::is_executable(&path) {
8070 return Ok(path);
8071 }
8072 bail!(
8073 "SPAR_SELF_BIN is set to {}, which is not executable",
8074 path.display()
8075 );
8076 }
8077 std::env::current_exe()
8078 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8079}
8080
8081fn bool_env(value: bool) -> &'static str {
8082 if value {
8083 "1"
8084 } else {
8085 "0"
8086 }
8087}
8088
8089pub fn sh_quote(text: &str) -> String {
8092 format!("'{}'", text.replace('\'', r"'\''"))
8093}
8094
8095pub fn style_from_env() -> Style {
8098 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8099 Style {
8100 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8101 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8102 ..Style::permissive()
8103 }
8104}
8105
8106#[cfg(test)]
8107mod tests {
8108 use super::*;
8109 use crate::config::StateStore;
8110 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8111 use std::process::Command;
8112
8113 fn repo_for_titles() -> Repo {
8114 Repo {
8115 root: PathBuf::from("/nonexistent"),
8116 style: Style::default(),
8117 branch_prefix: String::new(),
8118 state_store: StateStore::Local,
8119 followups: crate::config::Followups::Issues,
8120 drafts: Drafts::Never,
8121 viewer: OnceLock::new(),
8122 checkpoints: Mutex::new(BTreeMap::new()),
8123 writes: WriteStats::default(),
8124 }
8125 }
8126
8127 #[test]
8128 fn write_results_accumulate_for_the_run() {
8129 let repo = repo_for_titles();
8130
8131 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8132 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8133
8134 assert_eq!(
8135 WriteSummary {
8136 attempted: 2,
8137 failed: 1,
8138 },
8139 repo.write_summary()
8140 );
8141 }
8142
8143 #[test]
8144 fn only_failed_write_preflights_join_the_summary() {
8145 let repo = repo_for_titles();
8146
8147 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8148 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8149
8150 assert_eq!(
8151 WriteSummary {
8152 attempted: 1,
8153 failed: 1,
8154 },
8155 repo.write_summary()
8156 );
8157 }
8158
8159 #[test]
8160 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8161 let repo = repo_for_titles();
8162
8163 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8164 assert_eq!(
8165 WriteSummary {
8166 attempted: 1,
8167 failed: 1,
8168 },
8169 repo.write_summary()
8170 );
8171 }
8172
8173 #[test]
8174 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8175 let mut repo = repo_for_titles();
8176 repo.followups = Followups::Local;
8177
8178 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8179 assert_eq!(WriteSummary::default(), repo.write_summary());
8180 }
8181
8182 #[test]
8183 fn a_failed_remote_state_read_stops_before_state_mutation() {
8184 let root = std::env::temp_dir().join(format!(
8185 "spar-state-preflight-{}-{}",
8186 std::process::id(),
8187 std::time::SystemTime::now()
8188 .duration_since(std::time::UNIX_EPOCH)
8189 .unwrap()
8190 .as_nanos()
8191 ));
8192 std::fs::create_dir_all(&root).unwrap();
8193 let _fixture = ReviewFixture { root: root.clone() };
8194 let mut repo = repo_for_titles();
8195 repo.root = root;
8196 repo.state_store = StateStore::Both;
8197 let state = PersistedState {
8198 version: 1,
8199 checkpoint: 4,
8200 round: 2,
8201 next_actor: "a".into(),
8202 status: Status::Pending,
8203 pr_head: "abc123".into(),
8204 ledger: Ledger::new(),
8205 filed: Vec::new(),
8206 open_findings: Vec::new(),
8207 disputes: Vec::new(),
8208 noted: Vec::new(),
8209 };
8210
8211 let error = repo
8212 .write_state_after_remote_read(
8213 7,
8214 &state,
8215 Err(crate::error::SparError::new("state comments unavailable")),
8216 )
8217 .unwrap_err();
8218
8219 assert!(error.to_string().contains("state comments unavailable"));
8220 assert!(!repo.state_path(7).exists());
8221 assert_eq!(0, repo.remembered_checkpoint(7));
8222 assert_eq!(
8223 WriteSummary {
8224 attempted: 1,
8225 failed: 1,
8226 },
8227 repo.write_summary()
8228 );
8229 }
8230
8231 #[test]
8232 fn only_known_build_and_cache_directories_are_generated_artifacts() {
8233 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
8234 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
8235 assert!(is_generated_artifact(Path::new(
8236 "package/node_modules/dependency/file.js"
8237 )));
8238 assert!(!is_generated_artifact(Path::new(
8239 "distribution/required-package.js"
8240 )));
8241 assert!(!is_generated_artifact(Path::new(
8242 "generated/required-fixture.txt"
8243 )));
8244 assert!(!is_generated_artifact(Path::new("local.env")));
8245 }
8246
8247 #[test]
8248 fn only_finder_state_files_are_desktop_metadata() {
8249 assert!(is_desktop_metadata(Path::new(".DS_Store")));
8250 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
8251 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
8252 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
8253 }
8254
8255 struct ReviewFixture {
8256 root: PathBuf,
8257 }
8258
8259 impl Drop for ReviewFixture {
8260 fn drop(&mut self) {
8261 let _ = std::fs::remove_dir_all(&self.root);
8262 }
8263 }
8264
8265 fn test_git(cwd: &Path, args: &[&str]) -> String {
8266 let output = Command::new("git")
8267 .args(args)
8268 .current_dir(cwd)
8269 .output()
8270 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
8271 assert!(
8272 output.status.success(),
8273 "git {args:?} failed: {}",
8274 String::from_utf8_lossy(&output.stderr)
8275 );
8276 String::from_utf8_lossy(&output.stdout).into_owned()
8277 }
8278
8279 fn review_fixture(
8280 tag: &str,
8281 number: i64,
8282 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
8283 use std::sync::atomic::{AtomicU32, Ordering};
8284 static NEXT: AtomicU32 = AtomicU32::new(0);
8285 let id = NEXT.fetch_add(1, Ordering::Relaxed);
8286 let root =
8287 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
8288 let origin = root.join("origin.git");
8289 let work = root.join("work");
8290 std::fs::create_dir_all(&origin).unwrap();
8291 std::fs::create_dir_all(&work).unwrap();
8292 test_git(&origin, &["init", "--bare", "-b", "main"]);
8293 test_git(&work, &["init", "-b", "main"]);
8294 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
8295 test_git(&work, &["config", "user.name", "spar test"]);
8296 test_git(&work, &["config", "commit.gpgsign", "false"]);
8297 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
8298 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
8299 std::fs::write(work.join("README.md"), "seed\n").unwrap();
8300 std::fs::write(work.join("data.txt"), "old\n").unwrap();
8301 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
8302 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
8303 test_git(&work, &["add", "."]);
8304 test_git(&work, &["commit", "-m", "seed"]);
8305 test_git(
8306 &work,
8307 &["remote", "add", "origin", origin.to_str().unwrap()],
8308 );
8309 test_git(&work, &["push", "-u", "origin", "main"]);
8310 test_git(
8311 &work,
8312 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
8313 );
8314 let cfg = crate::config::parse(
8315 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
8316 )
8317 .unwrap();
8318 let repo = Repo::open(&work, &cfg).unwrap();
8319 let path = repo.worktree_for_pr_head(number).unwrap();
8320 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8321 (ReviewFixture { root }, repo, path, checkpoint)
8322 }
8323
8324 #[test]
8325 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
8326 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
8327
8328 repo.release_review_worktree_checked(901, &checkpoint)
8329 .unwrap();
8330
8331 assert!(!path.exists());
8332 }
8333
8334 #[test]
8335 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
8336 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
8337 let work = fixture.root.join("work");
8338 let seed = test_git(&work, &["rev-parse", "HEAD"]);
8339 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
8340 let publish = |message: &str| {
8343 let oid = test_git(
8344 &work,
8345 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
8346 );
8347 let oid = oid.trim().to_string();
8348 test_git(
8349 &work,
8350 &[
8351 "push",
8352 "--force",
8353 "origin",
8354 &format!("{oid}:refs/pull/923/head"),
8355 ],
8356 );
8357 oid
8358 };
8359 let reviewed = publish("reviewed head");
8360 repo.worktree_for_pr_head(923).unwrap();
8361 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
8362
8363 let rewritten = publish("rewritten head");
8364 repo.worktree_for_pr_head(923).unwrap();
8365
8366 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
8367 }
8368
8369 #[test]
8370 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
8371 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
8372 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
8373 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
8374 test_git(&path, &["add", "recovery.txt"]);
8375 test_git(&path, &["commit", "-m", "recovery commit"]);
8376 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
8377 test_git(&path, &["reset", "--hard", "main"]);
8378
8379 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
8380 test_git(
8381 &path,
8382 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8383 );
8384 }
8385
8386 #[test]
8387 fn a_review_ref_reflog_only_commit_prevents_deletion() {
8388 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
8389 let local_ref = review_ref(921);
8390 let original = test_git(&path, &["rev-parse", &local_ref]);
8391 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
8392 let recovery = test_git(
8393 &path,
8394 &[
8395 "commit-tree",
8396 tree.trim(),
8397 "-p",
8398 original.trim(),
8399 "-m",
8400 "review ref recovery",
8401 ],
8402 );
8403 test_git(
8404 &path,
8405 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
8406 );
8407 test_git(
8408 &path,
8409 &["update-ref", &local_ref, original.trim(), recovery.trim()],
8410 );
8411
8412 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
8413 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
8414 test_git(
8415 &path,
8416 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8417 );
8418 }
8419
8420 #[test]
8421 fn an_unpublished_commit_message_draft_is_recoverable() {
8422 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
8423 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
8424 let git_dir = if raw.is_absolute() {
8425 raw
8426 } else {
8427 path.join(raw)
8428 };
8429 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
8430
8431 assert!(repository_has_recoverable_work(&path, true).unwrap());
8432 assert_eq!(
8433 "unique recovery draft\n",
8434 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
8435 );
8436 }
8437
8438 #[test]
8439 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
8440 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
8441 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8442
8443 let error = repo
8444 .release_review_worktree_checked(902, &checkpoint)
8445 .unwrap_err();
8446
8447 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8448 assert!(error.to_string().contains("kept for recovery"), "{error}");
8449 assert_eq!(
8450 "recover me\n",
8451 std::fs::read_to_string(path.join("README.md")).unwrap()
8452 );
8453 repo.release_review_worktree(902);
8454 }
8455
8456 #[test]
8457 fn a_review_commit_is_retained_after_a_checked_read() {
8458 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
8459 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
8460 test_git(&path, &["add", "review-note.txt"]);
8461 test_git(&path, &["commit", "-m", "local review recovery"]);
8462 let head = test_git(&path, &["rev-parse", "HEAD"]);
8463
8464 let error = repo
8465 .release_review_worktree_checked(903, &checkpoint)
8466 .unwrap_err();
8467
8468 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8469 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
8470 assert_eq!(
8471 "recover me\n",
8472 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
8473 );
8474 repo.release_review_worktree(903);
8475 }
8476
8477 #[test]
8481 fn an_ignored_review_file_is_retained_after_a_checked_read() {
8482 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
8483 std::fs::create_dir_all(path.join("generated")).unwrap();
8484 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
8485
8486 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8487 .expect("its own output is not a change");
8488 let error = repo
8489 .release_review_worktree_checked(904, &checkpoint)
8490 .unwrap_err();
8491
8492 assert!(error.to_string().contains("recoverable"), "{error}");
8493 assert_eq!(
8494 "recover me\n",
8495 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
8496 );
8497 repo.release_review_worktree(904);
8498 }
8499
8500 #[test]
8501 fn a_preexisting_ignored_review_file_change_is_retained() {
8507 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
8508 std::fs::create_dir_all(path.join("generated")).unwrap();
8509 let ignored = path.join("generated/recovery.txt");
8510 std::fs::write(&ignored, "before\n").unwrap();
8511 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8512 std::fs::write(&ignored, "after!\n").unwrap();
8513
8514 let error = repo
8515 .release_review_worktree_checked(905, &checkpoint)
8516 .unwrap_err();
8517
8518 assert!(error.to_string().contains("recoverable"), "{error}");
8519 assert!(path.exists());
8520 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
8521 repo.release_review_worktree(905);
8522 }
8523
8524 #[test]
8525 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
8526 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
8527 std::fs::create_dir_all(path.join("generated")).unwrap();
8528 let ignored = path.join("generated/recovery.txt");
8529 std::fs::write(&ignored, "keep me\n").unwrap();
8530 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8531
8532 let error = repo
8533 .release_review_worktree_checked(906, &checkpoint)
8534 .unwrap_err();
8535
8536 assert!(error.to_string().contains("recoverable"), "{error}");
8537 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
8538 }
8539
8540 #[test]
8541 fn overwriting_a_preexisting_untracked_file_is_detected() {
8542 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
8543 let untracked = path.join("notes.txt");
8544 std::fs::write(&untracked, "before\n").unwrap();
8545 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8546 std::fs::write(&untracked, "after!\n").unwrap();
8547
8548 let error = repo
8549 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8550 .unwrap_err();
8551
8552 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8553 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
8554 }
8555
8556 #[test]
8557 fn an_assume_unchanged_edit_is_detected() {
8558 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
8559 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
8560 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
8561
8562 let error = repo
8563 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8564 .unwrap_err();
8565
8566 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8567 assert_eq!(
8568 "hidden\n",
8569 std::fs::read_to_string(path.join("README.md")).unwrap()
8570 );
8571 }
8572
8573 #[test]
8574 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
8575 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
8576 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8577 test_git(&path, &["add", "README.md"]);
8578 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8579
8580 let error = repo
8581 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8582 .unwrap_err();
8583
8584 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8585 assert_eq!(
8586 b"seed\r\n",
8587 std::fs::read(path.join("README.md")).unwrap().as_slice()
8588 );
8589 }
8590
8591 #[cfg(unix)]
8592 #[test]
8593 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
8594 use std::os::unix::fs::PermissionsExt;
8595
8596 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
8597 test_git(&path, &["config", "core.filemode", "false"]);
8598 let readme = path.join("README.md");
8599 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8600 permissions.set_mode(0o755);
8601 std::fs::set_permissions(&readme, permissions).unwrap();
8602 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8603
8604 let error = repo
8605 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8606 .unwrap_err();
8607
8608 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8609 assert_eq!(
8610 0o755,
8611 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8612 );
8613 }
8614
8615 #[test]
8616 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
8617 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
8618 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
8619 test_git(&path, &["add", ".gitattributes"]);
8620 test_git(&path, &["commit", "-m", "select data filter"]);
8621 let baseline = repo.worktree_baseline(&path).unwrap();
8622 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
8623
8624 assert!(repo
8625 .commit_pending_changes(&path, &baseline, "change data", "change data")
8626 .unwrap());
8627 let error = repo
8628 .refuse_unrepresented_tracked_changes(&path, &baseline)
8629 .unwrap_err();
8630
8631 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8632 assert_eq!(
8633 "secret: recover me\nnew\n",
8634 std::fs::read_to_string(path.join("data.txt")).unwrap()
8635 );
8636 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
8637 }
8638
8639 #[test]
8640 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
8641 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
8642 std::fs::create_dir_all(path.join("target")).unwrap();
8643 let untracked = path.join("target/user.yaml");
8644 std::fs::write(&untracked, "user data\n").unwrap();
8645 let baseline = repo.worktree_baseline(&path).unwrap();
8646 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8647
8648 assert!(repo
8649 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8650 .unwrap());
8651
8652 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
8653 assert_eq!(
8654 "?? target/user.yaml\n",
8655 test_git(&path, &["status", "--short", "--untracked-files=all"])
8656 );
8657 assert!(test_git(
8658 &path,
8659 &[
8660 "ls-tree",
8661 "-r",
8662 "--name-only",
8663 "HEAD",
8664 "--",
8665 "target/user.yaml"
8666 ]
8667 )
8668 .is_empty());
8669 }
8670
8671 #[test]
8672 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
8673 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
8674 std::fs::create_dir_all(path.join("target")).unwrap();
8675 let untracked = path.join("target/user.yaml");
8676 std::fs::write(&untracked, "before\n").unwrap();
8677 let baseline = repo.worktree_baseline(&path).unwrap();
8678 let before = test_git(&path, &["rev-parse", "HEAD"]);
8679 std::fs::write(&untracked, "after\n").unwrap();
8680 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8681
8682 let error = repo
8683 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8684 .unwrap_err();
8685
8686 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8687 assert!(error.to_string().contains("target/user.yaml"), "{error}");
8688 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8689 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
8690 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
8691 }
8692
8693 #[test]
8694 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
8695 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
8696 let baseline = repo.worktree_baseline(&path).unwrap();
8697 std::fs::create_dir_all(path.join("target")).unwrap();
8698 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
8699
8700 assert!(repo
8701 .commit_pending_changes(&path, &baseline, "add file", "add file")
8702 .unwrap());
8703
8704 assert_eq!(
8705 "new file\n",
8706 test_git(&path, &["show", "HEAD:target/new.txt"])
8707 );
8708 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8709 }
8710
8711 #[test]
8712 fn a_managed_commit_accepts_a_custom_output_rebuild() {
8713 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
8714 exclude_paths(&repo, &["manager/public/"]);
8715 let output = path.join("manager/public/assets");
8716 std::fs::create_dir_all(&output).unwrap();
8717 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
8718 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
8719 let baseline = repo.worktree_baseline(&path).unwrap();
8720 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8721 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
8722 std::fs::remove_file(output.join("old.js")).unwrap();
8723 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
8724 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
8725
8726 assert!(repo
8727 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8728 .unwrap());
8729 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8730 repo.refuse_changed_existing_untracked(&path, &baseline)
8731 .unwrap();
8732 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
8733 .unwrap();
8734
8735 assert_eq!(
8736 "tracked change\n",
8737 test_git(&path, &["show", "HEAD:README.md"])
8738 );
8739 assert_eq!(
8740 "new source\n",
8741 test_git(&path, &["show", "HEAD:new-source.txt"])
8742 );
8743 assert!(test_git(
8744 &path,
8745 &[
8746 "ls-tree",
8747 "-r",
8748 "--name-only",
8749 "HEAD",
8750 "--",
8751 "manager/public"
8752 ]
8753 )
8754 .is_empty());
8755 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8756 assert!(!output.join("old.js").exists());
8757 assert_eq!(
8758 "new styles\n",
8759 std::fs::read_to_string(output.join("index.css")).unwrap()
8760 );
8761 assert!(repository_has_recoverable_work(&path, true).unwrap());
8762 repo.release_review_worktree(912);
8763 assert!(output.join("index-DUym-Rfj.js").exists());
8764 }
8765
8766 #[test]
8767 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
8768 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
8769 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
8770 let baseline = repo.worktree_baseline(&path).unwrap();
8771 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
8772 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
8773
8774 let error = repo
8775 .commit_pending_changes(&path, &baseline, "change", "change")
8776 .unwrap_err();
8777
8778 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8779 assert!(error.to_string().contains("notes.txt"), "{error}");
8780 }
8781
8782 #[test]
8783 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
8784 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
8785 let baseline = repo.worktree_baseline(&path).unwrap();
8786 let nested = path.join("generated/repro");
8787 std::fs::create_dir_all(&nested).unwrap();
8788 test_git(&nested, &["init"]);
8789 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8790
8791 assert!(repo
8792 .commit_pending_changes(&path, &baseline, "change", "change")
8793 .unwrap());
8794 assert!(!repo.has_uncommitted_changes(&path).unwrap());
8795 assert!(repository_has_recoverable_work(&path, true).unwrap());
8796 repo.release_review_worktree(927);
8797 assert!(nested.join(".git").exists());
8798 }
8799
8800 #[test]
8801 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
8802 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
8803 test_git(&path, &["config", "core.autocrlf", "true"]);
8804 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8805 assert_eq!(
8806 test_git(&path, &["hash-object", "README.md"]).trim(),
8807 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8808 );
8809
8810 assert!(repository_has_recoverable_work(&path, true).unwrap());
8811 assert_eq!(
8812 "seed\n",
8813 std::fs::read_to_string(path.join("README.md")).unwrap()
8814 );
8815 }
8816
8817 #[test]
8818 fn autocrlf_input_overrides_a_crlf_core_eol() {
8819 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8820 test_git(&path, &["config", "core.autocrlf", "input"]);
8821 test_git(&path, &["config", "core.eol", "crlf"]);
8822 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8823 assert_eq!(
8824 test_git(&path, &["hash-object", "README.md"]).trim(),
8825 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8826 );
8827
8828 assert!(repository_has_recoverable_work(&path, true).unwrap());
8829 assert_eq!(
8830 b"seed\r\n",
8831 std::fs::read(path.join("README.md")).unwrap().as_slice()
8832 );
8833 }
8834
8835 #[cfg(unix)]
8836 #[test]
8837 fn a_non_executable_permission_change_is_recoverable() {
8838 use std::os::unix::fs::PermissionsExt;
8839
8840 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8841 let readme = path.join("README.md");
8842 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8843 permissions.set_mode(0o600);
8844 std::fs::set_permissions(&readme, permissions).unwrap();
8845 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8846
8847 let error = repo
8848 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8849 .unwrap_err();
8850
8851 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8852 assert!(repository_has_recoverable_work(&path, true).unwrap());
8853 assert_eq!(
8854 0o600,
8855 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8856 );
8857 }
8858
8859 #[cfg(unix)]
8860 #[test]
8861 fn a_managed_commit_skips_signing_and_hooks() {
8862 use std::os::unix::fs::PermissionsExt;
8863
8864 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8865 let common = common_git_dir(&path).unwrap();
8866 let hook = common.join("hooks/pre-commit");
8867 let marker = fixture.root.join("hook-ran");
8868 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8869 std::fs::write(
8870 &hook,
8871 format!(
8872 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8873 sh_quote(marker.to_str().unwrap())
8874 ),
8875 )
8876 .unwrap();
8877 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8878 permissions.set_mode(0o755);
8879 std::fs::set_permissions(&hook, permissions).unwrap();
8880 test_git(&path, &["config", "commit.gpgsign", "true"]);
8881 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8882 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8883 test_git(&path, &["add", "managed.txt"]);
8884
8885 repo.commit_staged_changes(&path, "record managed change")
8886 .unwrap();
8887
8888 assert!(!marker.exists());
8889 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8890 }
8891
8892 #[test]
8893 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8894 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8895 std::fs::write(
8896 path.join(".gitattributes"),
8897 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8898 )
8899 .unwrap();
8900 test_git(&path, &["add", ".gitattributes"]);
8901 test_git(&path, &["commit", "-m", "select automatic text"]);
8902 test_git(&path, &["config", "core.autocrlf", "true"]);
8903
8904 assert!(repository_has_recoverable_work(&path, true).unwrap());
8905 }
8906
8907 #[test]
8908 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8909 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8910 std::fs::write(
8911 path.join(".gitattributes"),
8912 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8913 )
8914 .unwrap();
8915 test_git(&path, &["add", ".gitattributes"]);
8916 test_git(&path, &["commit", "-m", "select ident expansion"]);
8917 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8918
8919 assert!(repository_has_recoverable_work(&path, true).unwrap());
8920 }
8921
8922 #[test]
8928 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8929 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8930 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8931 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8932 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8933 std::fs::write(
8934 path.join("bindings/uniffi-bindgen/Cargo.lock"),
8935 "# generated\n",
8936 )
8937 .unwrap();
8938
8939 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8940 .expect("a path the project ignores is the call's own output");
8941 }
8942
8943 #[test]
8944 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8945 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8946 exclude_paths(&repo, &["target/"]);
8947 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8948 std::fs::create_dir_all(path.join("target/package")).unwrap();
8949 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8950
8951 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8952 .expect("an ignored attribute file governs only ignored paths");
8953 assert!(
8954 !repo.has_uncommitted_changes(&path).unwrap(),
8955 "ignored dependency attributes must not stop the next review"
8956 );
8957 }
8958
8959 #[test]
8960 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
8961 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
8962 std::fs::create_dir_all(path.join("source")).unwrap();
8963 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
8964
8965 assert!(repo.has_uncommitted_changes(&path).unwrap());
8966 }
8967
8968 #[test]
8969 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
8970 for source in ["repository", "nested", "local", "global"] {
8971 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
8972 std::fs::create_dir_all(path.join("manager")).unwrap();
8973 match source {
8974 "repository" => {
8975 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
8976 .unwrap();
8977 test_git(&path, &["add", ".gitignore"]);
8978 test_git(&path, &["commit", "-m", "ignore artifacts"]);
8979 }
8980 "nested" => {
8981 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
8982 test_git(&path, &["add", "manager/.gitignore"]);
8983 test_git(&path, &["commit", "-m", "ignore artifacts"]);
8984 }
8985 "local" => exclude_paths(&repo, &["manager/scratch/"]),
8986 "global" => {
8987 let excludes = repo.root().parent().unwrap().join("global-ignore");
8988 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
8989 test_git(
8990 &path,
8991 &["config", "core.excludesFile", excludes.to_str().unwrap()],
8992 );
8993 }
8994 _ => unreachable!(),
8995 }
8996 let output = path.join("manager/scratch");
8997 std::fs::create_dir_all(&output).unwrap();
8998 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
8999 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
9000 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9001 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
9002 std::fs::remove_file(output.join(".DS_Store")).unwrap();
9003 for file in [
9004 ".gitattributes",
9005 "Cargo.lock",
9006 "font.woff2",
9007 "bundle.js",
9008 "cache.db",
9009 ] {
9010 std::fs::write(output.join(file), "generated\n").unwrap();
9011 }
9012 #[cfg(unix)]
9013 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
9014 let nested = output.join("dependency");
9015 std::fs::create_dir_all(&nested).unwrap();
9016 test_git(&nested, &["init"]);
9017 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9018 .unwrap_or_else(|error| panic!("{source}: {error}"));
9019 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9020
9021 let baseline = repo.worktree_baseline(&path).unwrap();
9022 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
9023 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
9024 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
9025 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
9026 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
9027 assert!(repo
9028 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
9029 .unwrap_or_else(|error| panic!("{source}: {error}")));
9030 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9031 assert!(test_git(
9032 &path,
9033 &[
9034 "ls-tree",
9035 "-r",
9036 "--name-only",
9037 "HEAD",
9038 "--",
9039 "manager/scratch"
9040 ]
9041 )
9042 .is_empty());
9043 assert!(
9044 repository_has_recoverable_work(&path, true).unwrap(),
9045 "{source}"
9046 );
9047 repo.release_review_worktree(945);
9048 assert_eq!(
9049 "LOCAL=updated\n",
9050 std::fs::read_to_string(output.join(".env.local")).unwrap()
9051 );
9052 assert!(nested.join(".git").exists(), "{source}");
9053 }
9054 }
9055
9056 #[test]
9060 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
9061 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
9062 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9063 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
9064
9065 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9066 .expect("spar's own marker is not something a call did");
9067 }
9068
9069 #[test]
9070 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
9071 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
9072
9073 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9074 .expect("a path a build removed is not an unreadable worktree");
9075
9076 assert_eq!(vanished_untracked_file(), gone);
9077 }
9078
9079 #[test]
9082 fn a_vanished_path_is_not_recorded_as_absent() {
9083 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9084 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9085 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9086 std::fs::remove_file(path.join("notes.txt")).unwrap();
9087
9088 let error = repo
9089 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9090 .unwrap_err();
9091
9092 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9093 }
9094
9095 fn exclude_paths(repo: &Repo, lines: &[&str]) {
9096 use std::io::Write;
9097 let path = repo.root().join(".git").join("info").join("exclude");
9098 let mut file = std::fs::OpenOptions::new()
9099 .create(true)
9100 .append(true)
9101 .open(&path)
9102 .unwrap();
9103 for line in lines {
9104 writeln!(file, "{line}").unwrap();
9105 }
9106 }
9107
9108 #[test]
9109 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9110 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9111 exclude_paths(&repo, &["dist/"]);
9112 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9113 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9114 let baseline = repo.worktree_baseline(&path).unwrap();
9115 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9116 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9117 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9118 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9119
9120 assert!(repo
9121 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9122 .unwrap());
9123 assert_eq!(
9124 "tracked change\n",
9125 std::fs::read_to_string(path.join("README.md")).unwrap()
9126 );
9127 }
9128
9129 #[test]
9130 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
9131 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
9132 exclude_paths(&repo, &["dist/"]);
9133 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9134 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9135 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
9136 let baseline = repo.worktree_baseline(&path).unwrap();
9137 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9138 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
9139 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9140 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9141
9142 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9143 repo.refuse_changed_existing_untracked(&path, &baseline)
9144 .unwrap();
9145 }
9146
9147 #[test]
9148 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
9149 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
9150 exclude_paths(&repo, &["dist/"]);
9151 std::fs::create_dir_all(path.join("dist")).unwrap();
9152 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9153 let baseline = repo.worktree_baseline(&path).unwrap();
9154 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9155 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9156
9157 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9158 repo.refuse_changed_existing_untracked(&path, &baseline)
9159 .unwrap();
9160 }
9161
9162 #[test]
9171 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
9172 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
9173 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
9174 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
9175 test_git(
9176 repo.root(),
9177 &[
9178 "worktree",
9179 "add",
9180 "-b",
9181 "issue-950",
9182 held.to_str().unwrap(),
9183 "main",
9184 ],
9185 );
9186
9187 let found = repo
9188 .worktree_holding("issue-950")
9189 .expect("the branch is checked out somewhere");
9190
9191 assert!(found.ends_with("issue-950"), "{}", found.display());
9192 assert_eq!(None, repo.worktree_holding("issue-951"));
9193 }
9194
9195 #[test]
9196 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
9197 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
9198 exclude_paths(&repo, &["manager/public/assets/"]);
9199 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
9200 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
9201 std::fs::write(&bundle, "first build\n").unwrap();
9202 let baseline = repo.worktree_baseline(&path).unwrap();
9203 std::fs::write(&bundle, "second build\n").unwrap();
9204 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
9205
9206 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
9207 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9208 }
9209
9210 #[test]
9211 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
9212 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
9213 exclude_paths(&repo, &["dist/"]);
9214 let baseline = repo.worktree_baseline(&path).unwrap();
9215 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
9216
9217 let error = repo
9218 .refuse_read_only_leavings(&path, &baseline)
9219 .unwrap_err();
9220
9221 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9222 assert!(error.to_string().contains("scratch.md"), "{error}");
9223 }
9224
9225 #[test]
9226 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
9227 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
9228 exclude_paths(&repo, &["dist/", ".env.local"]);
9229 std::fs::create_dir_all(path.join("dist")).unwrap();
9230 let baseline = repo.worktree_baseline(&path).unwrap();
9231 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
9232 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
9233
9234 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9235 assert!(repository_has_recoverable_work(&path, true).unwrap());
9236 }
9237
9238 #[test]
9239 fn a_read_only_inspection_may_rebuild_generated_output() {
9240 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
9241 exclude_paths(&repo, &["dist/"]);
9242 std::fs::create_dir_all(path.join("dist")).unwrap();
9243 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9244 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9245 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9246 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9247
9248 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9249 .unwrap();
9250 }
9251
9252 #[test]
9253 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
9254 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
9255 exclude_paths(&repo, &["dist/", ".env.local"]);
9256 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9257 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9258 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
9259
9260 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9261 .unwrap();
9262 }
9263
9264 #[test]
9265 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
9266 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
9267 exclude_paths(&repo, &["dist/", ".env.local"]);
9268 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9269 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9270 std::fs::remove_file(path.join(".env.local")).unwrap();
9271
9272 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9273 .unwrap();
9274 }
9275
9276 #[test]
9277 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
9278 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
9279 exclude_paths(&repo, &["dist/"]);
9280 std::fs::write(path.join("notes.md"), "before\n").unwrap();
9281 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9282 std::fs::write(path.join("notes.md"), "after\n").unwrap();
9283
9284 let error = repo
9285 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9286 .unwrap_err();
9287
9288 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9289 assert!(error.to_string().contains("notes.md"), "{error}");
9290 }
9291
9292 #[test]
9293 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
9294 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
9295 exclude_paths(&repo, &["dist/", "local.env"]);
9296 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
9297 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9298 std::fs::write(
9301 repo.root().join(".git").join("info").join("exclude"),
9302 "dist/\n",
9303 )
9304 .unwrap();
9305
9306 let error = repo
9307 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9308 .unwrap_err();
9309
9310 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9311 assert!(error.to_string().contains("local.env"), "{error}");
9312 }
9313
9314 #[test]
9315 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
9316 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
9317 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9318 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9319
9320 let error = repo
9321 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9322 .unwrap_err();
9323
9324 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9325 assert!(error.to_string().contains("README.md"), "{error}");
9326 }
9327
9328 #[test]
9329 fn build_output_alone_does_not_keep_a_worktree() {
9330 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
9331 exclude_paths(&repo, &["target/", "dist/"]);
9332 std::fs::create_dir_all(path.join("target/debug")).unwrap();
9333 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9334 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
9335 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
9336
9337 assert!(!repository_has_recoverable_work(&path, true).unwrap());
9338 repo.release_review_worktree(933);
9339
9340 assert!(!path.exists());
9341 }
9342
9343 #[test]
9344 fn finder_metadata_alone_does_not_keep_a_worktree() {
9345 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
9346 exclude_paths(&repo, &[".DS_Store"]);
9347 std::fs::create_dir_all(path.join("src")).unwrap();
9348 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
9349 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
9350
9351 assert!(!repository_has_recoverable_work(&path, true).unwrap());
9352 repo.release_review_worktree(937);
9353
9354 assert!(!path.exists());
9355 }
9356
9357 #[test]
9358 fn a_fetch_record_alone_does_not_keep_a_worktree() {
9359 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
9360 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9361 let git_dir = if raw.is_absolute() {
9362 raw
9363 } else {
9364 path.join(raw)
9365 };
9366 let head = test_git(&path, &["rev-parse", "HEAD"]);
9367 std::fs::write(
9368 git_dir.join("FETCH_HEAD"),
9369 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
9370 )
9371 .unwrap();
9372
9373 assert!(!repository_has_recoverable_work(&path, true).unwrap());
9374 repo.release_review_worktree(938);
9375
9376 assert!(!path.exists());
9377 }
9378
9379 #[test]
9380 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
9381 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
9382 exclude_paths(&repo, &["target/", ".env.local"]);
9383 std::fs::create_dir_all(path.join("target/debug")).unwrap();
9384 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9385 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
9386
9387 assert!(repository_has_recoverable_work(&path, true).unwrap());
9388 repo.release_review_worktree(934);
9389
9390 assert_eq!(
9391 "TOKEN=keep me\n",
9392 std::fs::read_to_string(path.join(".env.local")).unwrap()
9393 );
9394 }
9395
9396 #[test]
9397 fn a_repository_nested_in_build_output_keeps_a_worktree() {
9398 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
9399 exclude_paths(&repo, &["node_modules/"]);
9400 let nested = path.join("node_modules/local-dep");
9401 std::fs::create_dir_all(&nested).unwrap();
9402 test_git(&nested, &["init"]);
9403 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
9404
9405 assert!(repository_has_recoverable_work(&path, true).unwrap());
9406 repo.release_review_worktree(935);
9407
9408 assert!(nested.join(".git").exists());
9409 }
9410
9411 #[test]
9412 fn an_ordinary_untracked_file_keeps_a_worktree() {
9413 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
9414 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
9415
9416 assert!(repository_has_recoverable_work(&path, true).unwrap());
9417 repo.release_review_worktree(936);
9418
9419 assert_eq!(
9420 "somebody's notes\n",
9421 std::fs::read_to_string(path.join("notes.md")).unwrap()
9422 );
9423 }
9424
9425 #[test]
9426 fn a_legacy_crlf_checkout_is_retained_conservatively() {
9427 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
9428 std::fs::write(
9429 path.join(".gitattributes"),
9430 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
9431 )
9432 .unwrap();
9433 test_git(&path, &["add", ".gitattributes"]);
9434 test_git(&path, &["commit", "-m", "select legacy line endings"]);
9435
9436 assert!(repository_has_recoverable_work(&path, true).unwrap());
9437 }
9438
9439 #[test]
9440 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
9441 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
9442 let nested = path.join("tracked");
9443 std::fs::create_dir_all(&nested).unwrap();
9444 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
9445 test_git(&path, &["add", "tracked/seed.txt"]);
9446 test_git(&path, &["commit", "-m", "add tracked directory"]);
9447 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9448 test_git(&nested, &["init"]);
9449
9450 let error = repo
9451 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9452 .unwrap_err();
9453
9454 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9455 assert!(error.to_string().contains("Git entry"), "{error}");
9456 assert!(nested.join(".git").exists());
9457 }
9458
9459 #[test]
9464 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
9465 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
9466 exclude_paths(&repo, &["target/"]);
9467 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9468 let repro = path.join("target/pr918-repro");
9469 std::fs::create_dir_all(&repro).unwrap();
9470 test_git(&repro, &["init"]);
9471 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
9472
9473 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9474 .unwrap();
9475
9476 assert!(repository_has_recoverable_work(&path, true).unwrap());
9477 assert!(repro.join(".git").exists());
9478 }
9479
9480 #[test]
9484 fn a_repository_inside_a_nested_checkout_is_left_alone() {
9485 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
9486 exclude_paths(&repo, &["target/"]);
9487 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9488 let repro = path.join("target/repro");
9489 let vendored = repro.join("vendor/dep");
9490 std::fs::create_dir_all(&vendored).unwrap();
9491 test_git(&repro, &["init"]);
9492 test_git(&vendored, &["init"]);
9493
9494 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9495 .unwrap();
9496
9497 assert!(vendored.join(".git").exists());
9498 }
9499
9500 #[test]
9501 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
9502 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
9503
9504 let state = ignored_untracked_state(repo.root()).unwrap();
9505
9506 let relative = path.strip_prefix(repo.root()).unwrap();
9507 assert!(
9508 state.files.contains_key(relative),
9509 "{:?}",
9510 state.files.keys().collect::<Vec<_>>()
9511 );
9512 assert!(state.is_ignored(relative));
9513 }
9514
9515 #[test]
9516 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
9517 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
9518 let baseline = repo.worktree_baseline(repo.root()).unwrap();
9519 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
9520 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
9521
9522 repo.refuse_new_ignored_files(repo.root(), &baseline)
9523 .unwrap();
9524 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
9525 .unwrap();
9526 }
9527
9528 #[test]
9529 fn deleting_a_resident_worktree_during_a_call_is_refused() {
9530 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
9531 let baseline = repo.worktree_baseline(repo.root()).unwrap();
9532 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
9533 std::fs::remove_dir_all(&path).unwrap();
9534
9535 let read_error = repo
9536 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
9537 .unwrap_err();
9538 assert!(
9539 read_error.to_string().contains("review-932"),
9540 "{read_error}"
9541 );
9542 let error = repo
9543 .refuse_new_ignored_files(repo.root(), &baseline)
9544 .unwrap_err();
9545
9546 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9547 assert!(error.to_string().contains("review-932"), "{error}");
9548 }
9549
9550 #[test]
9551 fn a_nested_repository_record_is_read_as_a_plain_path() {
9552 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
9553 assert_eq!(Path::new("vendor/checkout"), path);
9554 assert!(nested);
9555
9556 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
9557 assert_eq!(Path::new("vendor/notes.txt"), path);
9558 assert!(!nested);
9559
9560 assert!(untracked_record(b"/", "untracked").is_err());
9561 }
9562
9563 #[cfg(unix)]
9564 #[test]
9565 fn a_non_utf8_git_path_is_preserved_without_loss() {
9566 use std::os::unix::ffi::OsStrExt;
9567
9568 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
9569
9570 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
9571 }
9572
9573 #[test]
9574 fn guarded_merge_pins_the_reviewed_head() {
9575 let args = merge_pr_args("36", Some("abc123"), true);
9576 assert_eq!(
9577 vec![
9578 "pr",
9579 "merge",
9580 "36",
9581 "--squash",
9582 "--delete-branch",
9583 "--match-head-commit",
9584 "abc123"
9585 ],
9586 args
9587 );
9588 }
9589
9590 #[test]
9591 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
9592 let pr = PrRef {
9593 number: 7,
9594 url: "https://example.test/pull/7".into(),
9595 title: "part one".into(),
9596 };
9597 let result = reconcile_pr_creation(
9598 "split-34-1",
9599 Err(crate::error::SparError::new("connection lost")),
9600 Ok(Some(pr)),
9601 )
9602 .unwrap();
9603 assert_eq!(7, result.number);
9604 }
9605
9606 #[test]
9607 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
9608 let error = reconcile_pr_creation(
9609 "split-34-1",
9610 Err(crate::error::SparError::new("permission denied")),
9611 Ok(None),
9612 )
9613 .unwrap_err();
9614 assert!(error.to_string().contains("permission denied"), "{error}");
9615 }
9616
9617 #[test]
9618 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
9619 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
9620 assert!(pr_for_base(text, "split-34-2", "split-34-1")
9621 .unwrap()
9622 .is_none());
9623 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
9624 assert_eq!(7, found.number);
9625 }
9626
9627 #[test]
9628 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
9629 let result = reconcile_comment_post(
9630 34,
9631 "the summary",
9632 crate::error::SparError::new("connection lost"),
9633 Ok(vec![serde_json::json!({"body": "the summary"})]),
9634 );
9635 assert!(result.is_ok(), "{result:?}");
9636 }
9637
9638 #[test]
9639 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
9640 let error = reconcile_comment_post(
9641 34,
9642 "the summary",
9643 crate::error::SparError::new("connection lost"),
9644 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
9645 )
9646 .unwrap_err();
9647 assert_eq!("connection lost", error.to_string());
9648 }
9649
9650 #[test]
9651 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
9652 let error = reconcile_comment_post(
9653 34,
9654 "the summary",
9655 crate::error::SparError::new("connection lost"),
9656 Err(crate::error::SparError::new("comments unavailable")),
9657 )
9658 .unwrap_err();
9659 assert!(
9660 error.to_string().contains("could not be verified"),
9661 "{error}"
9662 );
9663 assert!(
9664 error.to_string().contains("comments unavailable"),
9665 "{error}"
9666 );
9667 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9668 assert!(!error.worth_retrying());
9669 }
9670
9671 #[test]
9672 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
9673 let result = reconcile_issue_edit(
9674 34,
9675 "wanted body",
9676 crate::error::SparError::new("connection lost"),
9677 Ok("wanted body".to_string()),
9678 );
9679 assert!(result.is_ok(), "{result:?}");
9680 }
9681
9682 #[test]
9683 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
9684 let error = reconcile_issue_edit(
9685 34,
9686 "wanted body",
9687 crate::error::SparError::new("connection lost"),
9688 Err(crate::error::SparError::new("issue unavailable")),
9689 )
9690 .unwrap_err();
9691 assert!(
9692 error.to_string().contains("could not be verified"),
9693 "{error}"
9694 );
9695 assert!(error.to_string().contains("issue unavailable"), "{error}");
9696 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9697 assert!(!error.worth_retrying());
9698 }
9699
9700 #[test]
9701 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
9702 let found = ExistingIssue {
9703 number: 101,
9704 url: "https://example.test/issues/101".into(),
9705 title: "child".into(),
9706 body: "body".into(),
9707 open: true,
9708 };
9709 let url = reconcile_issue_creation(
9710 "child",
9711 Err(crate::error::SparError::new("connection lost")),
9712 Ok(Some(found)),
9713 )
9714 .unwrap();
9715 assert_eq!("https://example.test/issues/101", url);
9716 }
9717
9718 #[test]
9719 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
9720 let error = reconcile_issue_creation(
9721 "child",
9722 Err(crate::error::SparError::new("permission denied")),
9723 Ok(None),
9724 )
9725 .unwrap_err();
9726 assert!(error.to_string().contains("permission denied"), "{error}");
9727 }
9728
9729 #[test]
9730 fn an_unverifiable_issue_creation_is_marked_uncertain() {
9731 let error = reconcile_issue_creation(
9732 "child",
9733 Err(crate::error::SparError::new("connection lost")),
9734 Err(crate::error::SparError::new("issues unavailable")),
9735 )
9736 .unwrap_err();
9737 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9738 assert!(!error.worth_retrying());
9739 }
9740
9741 #[test]
9742 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
9743 let result = reconcile_failed_split_push(
9744 "split-34-1",
9745 crate::error::SparError::new("connection lost"),
9746 Ok("abc123\n".into()),
9747 Ok("abc123\trefs/heads/split-34-1\n".into()),
9748 );
9749 assert!(result.is_ok(), "{result:?}");
9750 }
9751
9752 #[test]
9753 fn a_split_push_collision_is_definite_and_never_overwrites() {
9754 let error = reconcile_failed_split_push(
9755 "split-34-1",
9756 crate::error::SparError::new("lease rejected"),
9757 Ok("abc123\n".into()),
9758 Ok("def456\trefs/heads/split-34-1\n".into()),
9759 )
9760 .unwrap_err();
9761 assert!(!error.retain_worktree());
9762 assert!(
9763 error.to_string().contains("Nothing was overwritten"),
9764 "{error}"
9765 );
9766 }
9767
9768 #[test]
9769 fn an_unreadable_split_push_result_keeps_the_worktree() {
9770 let error = reconcile_failed_split_push(
9771 "split-34-1",
9772 crate::error::SparError::new("connection lost"),
9773 Ok("abc123\n".into()),
9774 Err(crate::error::SparError::new("origin unavailable")),
9775 )
9776 .unwrap_err();
9777 assert!(error.retain_worktree());
9778 assert!(error.to_string().contains("could not confirm"), "{error}");
9779 }
9780
9781 #[test]
9785 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
9786 let repo = repo_for_titles();
9787 for raw in [
9788 "Retry loop spins \u{2014} Retry-After parses to zero",
9789 "plain title",
9790 " spread over\nlines ",
9791 "\u{1F916} Generated with something",
9792 &format!("a \u{2014} {}", "very long title ".repeat(20)),
9793 &"x".repeat(300),
9794 &format!("{} \u{2014} end", "y".repeat(88)),
9795 &{
9800 let tail = "a\u{2014}b c\u{2014}d";
9801 let pad = Style::default().max_title_chars - tail.chars().count();
9802 format!("{}{tail}", "w".repeat(pad))
9803 },
9804 ] {
9805 let once = repo.clean_title(raw).unwrap();
9806 let twice = repo.clean_title(&once).unwrap();
9807 assert_eq!(once, twice, "not idempotent for {raw:?}");
9808 assert!(
9809 once.chars().count() <= repo.style.max_title_chars,
9810 "over budget: {once:?}"
9811 );
9812 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
9813 }
9814 }
9815
9816 #[test]
9817 fn a_title_with_an_em_dash_survives_as_readable_text() {
9818 let repo = repo_for_titles();
9819 assert_eq!(
9820 "Retry loop spins, Retry-After parses to zero",
9821 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
9822 .unwrap()
9823 );
9824 }
9825
9826 #[test]
9827 fn sh_quote_survives_a_quote() {
9828 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
9829 }
9830
9831 #[test]
9832 fn sh_quote_wraps_a_space() {
9833 assert_eq!(
9834 "'/Applications/My App/spar'",
9835 sh_quote("/Applications/My App/spar")
9836 );
9837 }
9838
9839 #[test]
9840 fn finished_states_are_recognised_case_insensitively() {
9841 assert!(is_finished("MERGED"));
9842 assert!(is_finished("closed"));
9843 assert!(!is_finished("OPEN"));
9844 assert!(!is_finished(""));
9845 }
9846
9847 fn state() -> PersistedState {
9848 PersistedState {
9849 version: 1,
9850 checkpoint: 0,
9851 round: 4,
9852 next_actor: "codex".into(),
9853 status: Status::Pending,
9854 pr_head: "abc123".into(),
9855 ledger: Ledger::new(),
9856 filed: vec![],
9857 open_findings: vec![Finding {
9858 severity: Severity::Blocking,
9859 title: "Unchecked error".into(),
9860 detail: "the failure is discarded".into(),
9861 file: "src/a.rs:12".into(),
9862 ..Finding::default()
9863 }],
9864 disputes: vec![Dispute {
9865 title: "Retry limit".into(),
9866 file: "src/net.rs".into(),
9867 reasoning: "the caller already bounds it".into(),
9868 }],
9869 noted: vec![Finding {
9870 severity: Severity::NonBlocking,
9871 title: "Timeout is fixed".into(),
9872 file: "src/config.rs".into(),
9873 ..Finding::default()
9874 }],
9875 }
9876 }
9877
9878 #[test]
9879 fn a_state_comment_round_trips() {
9880 let body = format!(
9881 "{STATE_MARKER}\n{}\n-->",
9882 serde_json::to_string(&state()).unwrap()
9883 );
9884 let back = parse_state_comment(&body).unwrap();
9885 assert_eq!(4, back.round);
9886 assert_eq!("codex", back.next_actor);
9887 assert_eq!("abc123", back.pr_head);
9888 assert_eq!("Unchecked error", back.open_findings[0].title);
9889 assert_eq!("src/net.rs", back.disputes[0].file);
9890 assert_eq!("Timeout is fixed", back.noted[0].title);
9891 }
9892
9893 #[test]
9894 fn old_state_without_new_lists_still_parses() {
9895 let body = format!(
9896 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
9897 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9898 );
9899 let back = parse_state_comment(&body).expect("old state");
9900 assert!(back.open_findings.is_empty());
9901 assert!(back.disputes.is_empty());
9902 assert!(back.noted.is_empty());
9903 assert!(back.pr_head.is_empty());
9904 assert_eq!(0, back.checkpoint);
9905 }
9906
9907 #[test]
9908 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
9909 let mut local = state();
9910 local.pr_head = "old".into();
9911 local.round = 9;
9912 let mut remote = state();
9913 remote.pr_head = "current".into();
9914 remote.round = 4;
9915
9916 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9917 assert_eq!("current", chosen.pr_head);
9918 assert_eq!(4, chosen.round);
9919 }
9920
9921 #[test]
9922 fn checkpoint_order_breaks_same_round_ties() {
9923 let mut local = state();
9924 local.pr_head = "current".into();
9925 local.round = 4;
9926 local.checkpoint = 8;
9927 let mut remote = local.clone();
9928 remote.checkpoint = 7;
9929 remote.open_findings.clear();
9930
9931 let chosen = choose_state_for_head(vec![local], "current").unwrap();
9932 assert_eq!(8, chosen.checkpoint);
9933
9934 let mut local = state();
9935 local.pr_head = "current".into();
9936 local.round = 4;
9937 local.checkpoint = 8;
9938 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
9939 assert_eq!(8, chosen.checkpoint);
9940 }
9941
9942 #[test]
9943 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
9944 let mut local = state();
9945 local.pr_head = "current".into();
9946 local.round = 4;
9947 local.open_findings.push(Finding {
9948 title: "local checkpoint".into(),
9949 ..Finding::default()
9950 });
9951 let mut remote = state();
9952 remote.pr_head = "current".into();
9953 remote.round = 4;
9954
9955 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9956 assert_eq!(
9957 "local checkpoint",
9958 chosen.open_findings.last().unwrap().title
9959 );
9960 }
9961
9962 #[test]
9964 fn the_state_block_is_an_html_comment() {
9965 let body = format!(
9966 "{STATE_MARKER}\n{}\n-->",
9967 serde_json::to_string(&state()).unwrap()
9968 );
9969 assert!(body.starts_with("<!--"));
9970 assert!(body.trim_end().ends_with("-->"));
9971 assert!(!body[..body.find('{').unwrap()].contains("-->"));
9972 }
9973
9974 #[test]
9975 fn an_unrelated_json_block_is_not_state() {
9976 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
9977 }
9978
9979 #[test]
9986 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
9987 let block = format!(
9988 "{STATE_MARKER}\n{}\n-->",
9989 serde_json::to_string(&state()).unwrap()
9990 );
9991 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
9992 assert!(
9993 is_state_comment(&block, "Spar-Bot", "spar-bot"),
9994 "a login differs only in case"
9995 );
9996
9997 assert!(
9998 !is_state_comment(
9999 "why is there a `<!-- spar:state` block on this PR?",
10000 "maintainer",
10001 "spar-bot"
10002 ),
10003 "a question about the block was treated as the block"
10004 );
10005 assert!(
10006 !is_state_comment(&block, "maintainer", "spar-bot"),
10007 "somebody else's comment is not spar's to rewrite"
10008 );
10009 assert!(
10010 !is_state_comment(
10011 &format!("here is what spar writes:\n\n{block}"),
10012 "spar-bot",
10013 "spar-bot"
10014 ),
10015 "the marker has to open the body, not appear in it"
10016 );
10017 assert!(
10018 !is_state_comment(
10019 &format!("{STATE_MARKER}\n{{not json\n-->"),
10020 "spar-bot",
10021 "spar-bot"
10022 ),
10023 "a block that does not parse is not state"
10024 );
10025 assert!(
10026 !is_state_comment(&block, "spar-bot", ""),
10027 "an unknown viewer proves nothing about who wrote it"
10028 );
10029 }
10030
10031 #[test]
10032 fn a_malformed_state_comment_is_none_not_a_panic() {
10033 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
10034 }
10035
10036 #[test]
10037 fn atomic_write_leaves_no_temp_file() {
10038 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
10039 let _ = std::fs::remove_dir_all(&dir);
10040 let path = dir.join("state").join("pr-7.json");
10041 write_json_atomic(&path, &state()).unwrap();
10042 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
10043 .unwrap()
10044 .flatten()
10045 .filter_map(|e| e.file_name().to_str().map(str::to_string))
10046 .collect();
10047 assert_eq!(vec!["pr-7.json".to_string()], files);
10048 let _ = std::fs::remove_dir_all(&dir);
10049 }
10050
10051 #[test]
10052 fn atomic_write_overwrites_rather_than_accumulating() {
10053 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
10054 let _ = std::fs::remove_dir_all(&dir);
10055 let path = dir.join("pr-7.json");
10056 for round in 1..4 {
10057 let mut s = state();
10058 s.round = round;
10059 write_json_atomic(&path, &s).unwrap();
10060 }
10061 let back: PersistedState =
10062 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10063 assert_eq!(3, back.round);
10064 let _ = std::fs::remove_dir_all(&dir);
10065 }
10066
10067 #[test]
10068 fn style_from_env_defaults_to_enforcing() {
10069 std::env::remove_var("SPAR_BAN_EM_DASH");
10070 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
10071 let style = style_from_env();
10072 assert!(style.ban_em_dash && style.ban_ai_attribution);
10073 assert!(
10074 !style.terse,
10075 "the commit filter must not truncate a commit message"
10076 );
10077 }
10078}
10079
10080#[cfg(test)]
10081mod comment_page_tests {
10082 use super::*;
10083
10084 #[test]
10085 fn a_single_merged_array_is_read() {
10086 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
10087 assert_eq!(2, pages.len());
10088 assert_eq!(Some(2), pages[1]["id"].as_i64());
10089 }
10090
10091 #[test]
10092 fn concatenated_pages_from_an_older_gh_are_read_too() {
10093 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
10094 assert_eq!(2, pages.len());
10095 }
10096
10097 #[test]
10101 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
10102 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
10103 let pages = parse_comment_pages(text);
10104 assert_eq!(2, pages.len(), "{pages:?}");
10105 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
10106 }
10107
10108 #[test]
10109 fn empty_output_is_no_comments_not_a_panic() {
10110 assert!(parse_comment_pages("").is_empty());
10111 assert!(parse_comment_pages(" ").is_empty());
10112 assert!(parse_comment_pages("[]").is_empty());
10113 }
10114
10115 #[test]
10116 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
10117 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
10118 }
10119
10120 #[test]
10121 fn a_write_postcheck_rejects_truncated_comment_pages() {
10122 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
10123 assert!(
10124 error.to_string().contains("unexpected comment pages"),
10125 "{error}"
10126 );
10127 }
10128
10129 #[test]
10130 fn a_write_postcheck_rejects_empty_or_non_array_output() {
10131 assert!(try_parse_comment_pages("").is_err());
10132 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
10133 assert!(try_parse_comment_pages("[]").is_ok());
10134 }
10135
10136 #[test]
10137 fn state_is_found_in_the_last_matching_comment() {
10138 let payload = |round: u32| {
10139 format!(
10140 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10141 )
10142 };
10143 let text = serde_json::to_string(&serde_json::json!([
10144 {"id": 1, "body": payload(1)},
10145 {"id": 2, "body": "looks good to me"},
10146 {"id": 3, "body": payload(5)},
10147 ]))
10148 .unwrap();
10149 let pages = parse_comment_pages(&text);
10150 let last = pages
10151 .iter()
10152 .rev()
10153 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
10154 .unwrap();
10155 assert_eq!(5, last.round);
10156 }
10157}
10158
10159#[cfg(test)]
10160mod linked_pr_tests {
10161 use super::*;
10162
10163 const REAL_PAYLOAD: &str = r#"[
10168 {"number":14252,"title":"fix: reject leading-dash branch names",
10169 "url":"https://github.com/cli/cli/pull/14252",
10170 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
10171 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
10172 "url":"https://github.com/cli/cli/issues/14238"}]},
10173 {"number":14217,"title":"another change",
10174 "url":"https://github.com/cli/cli/pull/14217",
10175 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
10176 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
10177 "url":"https://github.com/cli/cli/issues/9761"}]},
10178 {"number":14200,"title":"unlinked work",
10179 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
10180 ]"#;
10181
10182 #[test]
10183 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
10184 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
10185 assert_eq!(14252, pr.number);
10186 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
10187 }
10188
10189 #[test]
10190 fn the_right_pr_is_picked_out_of_several() {
10191 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
10192 }
10193
10194 #[test]
10195 fn an_issue_nobody_is_working_on_finds_nothing() {
10196 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
10197 }
10198
10199 #[test]
10200 fn an_unlinked_pr_is_never_matched() {
10201 for issue in [14200, 0, 1] {
10203 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
10204 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
10205 }
10206 }
10207 }
10208
10209 #[test]
10210 fn empty_or_broken_output_is_none_rather_than_a_panic() {
10211 assert!(find_linked_pr("", 1).is_none());
10212 assert!(find_linked_pr("[]", 1).is_none());
10213 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
10214 assert!(find_linked_pr("[{\"number\":", 1).is_none());
10215 }
10216
10217 #[test]
10224 fn an_unreadable_list_is_not_an_empty_list() {
10225 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
10226 assert!(linked_pr_rows("[{\"number\":").is_none());
10227 assert!(linked_pr_rows("").is_none());
10228
10229 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
10230 assert!(empty.is_empty());
10231 assert!(pick_linked_pr(&empty, 14238).is_none());
10232
10233 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
10234 assert_eq!(3, listed.len());
10235 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
10236 }
10237
10238 #[test]
10240 fn pr_view_reads_the_cross_repository_flag() {
10241 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
10242 "baseRefName":"main","state":"OPEN",
10243 "closingIssuesReferences":[],"isCrossRepository":true}"#;
10244 let pr: PrView = serde_json::from_str(json).unwrap();
10245 assert!(pr.is_cross_repository);
10246 assert!(pr.is_open());
10247
10248 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
10249 assert!(
10250 !serde_json::from_str::<PrView>(&same_repo)
10251 .unwrap()
10252 .is_cross_repository
10253 );
10254 }
10255}
10256
10257#[cfg(test)]
10258mod min_number_tests {
10259 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
10265 let mut numbers: Vec<i64> = open.to_vec();
10266 numbers.sort_unstable();
10267 if min_number > 0 {
10268 numbers.retain(|n| *n >= min_number);
10269 }
10270 numbers.truncate(limit);
10271 numbers
10272 }
10273
10274 #[test]
10275 fn the_floor_is_applied_before_the_cap_not_after() {
10276 let open = [12, 13, 14, 480, 481, 482];
10277 assert_eq!(vec![480, 481], pick(&open, 2, 480));
10278 assert!(!pick(&open, 2, 480).is_empty());
10281 }
10282
10283 #[test]
10284 fn no_floor_keeps_the_old_behaviour() {
10285 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
10286 }
10287
10288 #[test]
10289 fn the_floor_is_inclusive() {
10290 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
10291 }
10292
10293 #[test]
10294 fn a_floor_above_everything_open_yields_nothing() {
10295 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
10296 }
10297}
10298
10299#[cfg(test)]
10300mod review_record_tests {
10301 use super::*;
10302 use serde_json::json;
10303
10304 #[test]
10305 fn prune_state_knows_which_files_belong_to_a_pull_request() {
10306 assert_eq!(Some(12), state_file_number("pr-12.json"));
10307 assert_eq!(Some(12), state_file_number("review-12.json"));
10308 assert_eq!(Some(12), state_file_number("checkin-12.json"));
10309 for shared in [
10310 "spend.json",
10311 "implementors.json",
10312 "contested.json",
10313 "listen.json",
10314 "pr-.json",
10315 "pr-12.json.tmp",
10316 "pr-x.json",
10317 ] {
10318 assert_eq!(None, state_file_number(shared), "{shared}");
10319 }
10320 }
10321
10322 #[test]
10323 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
10324 let reviews = vec![
10325 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
10326 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
10327 ];
10328 assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
10329 assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
10330 assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
10331 }
10332
10333 #[test]
10337 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
10338 let older = vec![
10339 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
10340 ];
10341 assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
10342 let dismissed = vec![
10343 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
10344 ];
10345 assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
10346 }
10347
10348 #[test]
10349 fn a_response_splits_into_status_headers_and_body() {
10350 let text = "HTTP/2.0 200 OK\r\nX-Poll-Interval: 60\r\nLast-Modified: Tue, 20 Oct 2026 10:00:00 GMT\r\nContent-Type: application/json\r\n\r\n[{\"id\": \"1\"}]\n";
10351 let resp = parse_api_response(text).expect("a response");
10352 assert_eq!(200, resp.status);
10353 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
10354 assert_eq!(
10355 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
10356 resp.headers.get("last-modified")
10357 );
10358 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
10359
10360 let not_modified =
10361 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
10362 assert_eq!(304, not_modified.status);
10363 assert!(not_modified.body.is_empty());
10364
10365 assert_eq!(None, parse_api_response(""));
10366 assert_eq!(None, parse_api_response("gh: command not found\n"));
10367 }
10368
10369 #[test]
10370 fn review_requests_are_read_off_the_timeline_with_who_and_when() {
10371 let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
10372 {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
10373 {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
10374 {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
10375 {}
10376 ]}}}}})
10377 .to_string();
10378 let made = parse_review_requests(&text).expect("requests");
10379 assert_eq!(2, made.len());
10380 assert_eq!("2026-10-20T10:00:00Z", made[0].at);
10381 assert_eq!("viewer", made[0].who.login);
10382 assert!(made[0].who.slug.is_empty());
10383 assert_eq!("2026-10-20T11:00:00Z", made[1].at);
10384 assert!(made[1].who.login.is_empty());
10385 assert_eq!("example/ours", made[1].who.slug);
10386
10387 let none =
10388 json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
10389 assert_eq!(
10390 Some(0),
10391 parse_review_requests(&none.to_string()).map(|m| m.len())
10392 );
10393 assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
10394 assert!(parse_review_requests("gh: not json").is_none());
10395 }
10396
10397 #[test]
10398 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
10399 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
10400 let error = crate::error::SparError::call_failed("gh: boom");
10401 assert!(
10402 reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
10403 );
10404
10405 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
10406 let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
10407 .unwrap_err();
10408 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
10409
10410 let unreadable = Err(crate::error::SparError::new("no network"));
10411 let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
10412 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
10413 assert!(err.to_string().contains("could not be verified"), "{err}");
10414 }
10415}