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