1use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20 Contested, ContestedItem, Followup, Issue, IssueRef, ItemKind, LiveHead, PersistedState, PrRef,
21 PrRow, PrSweepRow, PrView, PR_SWEEP_FIELDS,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, logdim, logwarn, spar_err};
27
28const ISSUE_FIELDS: &str = "number,title,body,labels,state,stateReason,url,author,comments";
36
37pub const FETCH_CEILING: usize = 500;
41
42pub const STATE_MARKER: &str = "<!-- spar:state";
45
46pub const COMMENT_MARKER: &str = "<!-- spar:comment -->";
54
55pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
63
64const WORKTREE_DIR: &str = ".spar-worktrees";
65const STATE_DIR: &str = ".spar";
66
67pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
69pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) enum Disposable {
74 BuildOutput,
77 ReviewOutput,
85}
86
87pub(crate) fn is_spar_marker(path: &Path) -> bool {
95 let mut components = path.components();
96 let Some(std::path::Component::Normal(name)) = components.next() else {
97 return false;
98 };
99 if components.next().is_some() {
100 return false;
101 }
102 let Some(name) = name.to_str() else {
103 return false;
104 };
105 name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
106}
107
108pub(crate) fn is_spar_own_path(path: &Path) -> bool {
121 if is_spar_marker(path) {
122 return true;
123 }
124 let Some(std::path::Component::Normal(name)) = path.components().next() else {
125 return false;
126 };
127 name == OsStr::new(STATE_DIR)
128}
129
130const SPLIT_SLOTS: u32 = 20;
135
136#[derive(Debug, Clone)]
137pub struct SplitPushError {
138 message: String,
139 retain_worktree: bool,
140}
141
142impl SplitPushError {
143 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
144 Self {
145 message: message.into(),
146 retain_worktree,
147 }
148 }
149
150 pub fn retain_worktree(&self) -> bool {
151 self.retain_worktree
152 }
153}
154
155impl std::fmt::Display for SplitPushError {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.write_str(&self.message)
158 }
159}
160
161impl std::error::Error for SplitPushError {}
162
163fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
168 match attempt {
169 1 => format!("split-{parent}-{index}"),
170 n => format!("split-{parent}-{index}-{n}"),
171 }
172}
173
174#[derive(Debug)]
175pub struct Repo {
176 root: PathBuf,
177 pub style: Style,
178 pub branch_prefix: String,
179 pub state_store: StateStore,
180 pub followups: Followups,
181 pub drafts: Drafts,
182 viewer: OnceLock<String>,
188 checkpoints: Mutex<BTreeMap<i64, u64>>,
193 writes: WriteStats,
194}
195
196#[derive(Debug, Default)]
197struct WriteStats {
198 attempted: AtomicUsize,
199 failed: AtomicUsize,
200}
201
202#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
203pub(crate) struct WriteSummary {
204 pub(crate) attempted: usize,
205 pub(crate) failed: usize,
206}
207
208impl WriteSummary {
209 pub(crate) fn succeeded(self) -> usize {
210 self.attempted.saturating_sub(self.failed)
211 }
212}
213
214#[derive(Debug, Clone)]
220pub(crate) struct WorktreeBaseline {
221 attributes: AttributeState,
222 ignored_untracked: IgnoredState,
223 git_state: GitState,
224}
225
226#[derive(Debug, Clone)]
232pub(crate) struct WorktreeCheckpoint {
233 path: PathBuf,
234 attributes: AttributeState,
235 git_state: GitState,
236 ignored_untracked: IgnoredState,
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq)]
240pub(crate) struct AttributeState {
241 files: BTreeMap<PathBuf, [u8; 32]>,
242}
243
244#[derive(Debug, Clone, Default, PartialEq, Eq)]
249pub(crate) struct IgnoredState {
250 files: BTreeMap<PathBuf, UntrackedFile>,
251 ignored: BTreeSet<PathBuf>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
261struct UntrackedFile {
262 kind: u8,
263 len: u64,
264 modified: Option<std::time::SystemTime>,
265 created: Option<std::time::SystemTime>,
266 readonly: bool,
267 symlink_target: Option<Vec<u8>>,
268 #[cfg(unix)]
269 device: u64,
270 #[cfg(unix)]
271 inode: u64,
272 #[cfg(unix)]
273 mode: u32,
274 #[cfg(unix)]
275 change_seconds: i64,
276 #[cfg(unix)]
277 change_nanoseconds: i64,
278}
279
280#[derive(Debug, Clone, Default, PartialEq, Eq)]
281pub(crate) struct GitState {
282 repositories: BTreeMap<PathBuf, RepositoryState>,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
286struct RepositoryState {
287 head: String,
288 unsafe_index_flags: Vec<u8>,
289 tracked: BTreeMap<PathBuf, TrackedEntry>,
290 gitlinks: BTreeMap<PathBuf, String>,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
294struct TrackedEntry {
295 index_mode: String,
296 index_oid: String,
297 worktree: Option<WorktreeFile>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
301struct WorktreeFile {
302 mode: String,
303 #[cfg(unix)]
304 permissions: u32,
305 raw_oid: String,
306 fingerprint: [u8; 32],
307 content: [u8; 32],
308}
309
310struct Gitlink {
311 path: PathBuf,
312 oid: String,
313}
314
315struct IndexEntry {
316 path: PathBuf,
317 mode: String,
318 oid: String,
319}
320
321impl IgnoredState {
322 fn is_ignored(&self, path: &Path) -> bool {
323 self.ignored.contains(path)
324 }
325
326 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
327 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
328 paths.extend(after.files.keys().cloned());
329 paths
330 .into_iter()
331 .filter(|path| {
332 self.files.get(path) != after.files.get(path)
333 || self.is_ignored(path) != after.is_ignored(path)
334 })
335 .collect()
336 }
337
338 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
339 self.files
340 .iter()
341 .filter(|(path, state)| after.files.get(*path) != Some(*state))
342 .map(|(path, _)| path.clone())
343 .collect()
344 }
345
346 fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
347 after
348 .files
349 .keys()
350 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
351 .cloned()
352 .collect()
353 }
354
355 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
365 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
366 paths.extend(after.files.keys());
367 paths.into_iter().any(|path| {
368 if self.files.get(path) == after.files.get(path)
369 && self.is_ignored(path) == after.is_ignored(path)
370 {
371 return false;
372 }
373 !self.generated_move(after, path)
374 })
375 }
376
377 pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
405 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
406 paths.extend(after.files.keys());
407 let mut leavings = ReadOnlyLeavings::default();
408 for path in paths {
409 if self.files.get(path) == after.files.get(path)
410 && self.is_ignored(path) == after.is_ignored(path)
411 {
412 continue;
413 }
414 if !self.ignored_file_move(after, path) {
415 leavings.condemning.push(path.clone());
416 } else if !is_generated_artifact(path) {
417 leavings.tolerated.push(path.clone());
418 }
419 }
420 leavings
421 }
422
423 fn generated_move(&self, after: &Self, path: &Path) -> bool {
437 is_generated_artifact(path) && self.disposable_move(after, path)
438 }
439
440 fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
444 if path.starts_with(WORKTREE_DIR)
445 && self.files.get(path).is_some_and(|file| file.kind == 3)
446 && self.files.get(path) != after.files.get(path)
447 {
448 return false;
449 }
450 self.disposable_move(after, path)
451 }
452
453 fn disposable_move(&self, after: &Self, path: &Path) -> bool {
456 self.disposable(path) && after.disposable(path)
457 }
458
459 fn disposable(&self, path: &Path) -> bool {
462 !self.files.contains_key(path) || self.is_ignored(path)
463 }
464}
465
466impl GitState {
467 pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
473 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
474 prefixes.extend(after.repositories.keys());
475 let mut notes: Vec<String> = Vec::new();
476 let mut paths: Vec<PathBuf> = Vec::new();
477 for prefix in prefixes {
478 let label = describe_repository(prefix);
479 match (
480 self.repositories.get(prefix),
481 after.repositories.get(prefix),
482 ) {
483 (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
484 (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
485 (Some(before), Some(current)) => {
486 if before.head != current.head {
487 notes.push(format!("HEAD moved in {label}"));
488 }
489 if before.unsafe_index_flags != current.unsafe_index_flags {
490 notes.push(format!("index flags changed in {label}"));
491 }
492 paths.extend(
493 differing_keys(&before.gitlinks, ¤t.gitlinks)
494 .chain(differing_keys(&before.tracked, ¤t.tracked))
495 .map(|path| prefix.join(path)),
496 );
497 }
498 (None, None) => {}
499 }
500 }
501 if !paths.is_empty() {
502 paths.sort();
503 paths.dedup();
504 notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
505 }
506 (!notes.is_empty()).then(|| notes.join("; "))
507 }
508
509 pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
512 let difference = self.describe_difference(after)?;
513 let Some(moved) = self.moved_wholesale(after) else {
514 return Some(difference);
515 };
516 if said_once("moved", cwd, &moved) {
519 logwarn!(
520 "the checkout at {} moved while a read-only call was reading it: {moved}. The \
521 answer was kept, and it describes the tree as it stood before the move.",
522 cwd.display()
523 );
524 }
525 None
526 }
527
528 fn moved_wholesale(&self, after: &Self) -> Option<String> {
545 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
546 prefixes.extend(after.repositories.keys());
547 let mut notes: Vec<String> = Vec::new();
548 for prefix in prefixes {
549 let before = self.repositories.get(prefix)?;
551 let current = after.repositories.get(prefix)?;
552 if before.unsafe_index_flags != current.unsafe_index_flags
553 || before.gitlinks != current.gitlinks
554 {
555 return None;
556 }
557 let moved: Vec<&PathBuf> = differing_keys(&before.tracked, ¤t.tracked).collect();
558 if before.head == current.head {
559 if moved.is_empty() {
560 continue;
561 }
562 return None;
563 }
564 if !moved.iter().all(|path| {
565 checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
566 }) {
567 return None;
568 }
569 let label = describe_repository(prefix);
570 notes.push(match moved.len() {
571 0 => format!("HEAD moved in {label}"),
572 count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
573 });
574 }
575 (!notes.is_empty()).then(|| notes.join("; "))
576 }
577}
578
579fn checked_out(entry: Option<&TrackedEntry>) -> bool {
586 entry.is_none_or(|entry| {
587 entry
588 .worktree
589 .as_ref()
590 .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
591 })
592}
593
594pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
605 if paths.is_empty() {
606 return;
607 }
608 let named = list_paths(paths);
609 if !said_once("ignored", cwd, &named) {
610 return;
611 }
612 logdim!(
613 "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
614 project ignores these paths.",
615 paths.len(),
616 cwd.display()
617 );
618}
619
620fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
627 static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
628 SAID.lock()
629 .unwrap_or_else(std::sync::PoisonError::into_inner)
630 .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
631}
632
633fn describe_repository(prefix: &Path) -> String {
636 if prefix.as_os_str().is_empty() {
637 "the worktree".to_string()
638 } else {
639 format!("the submodule at {:?}", prefix.as_os_str())
640 }
641}
642
643fn differing_keys<'a, V: PartialEq>(
645 before: &'a BTreeMap<PathBuf, V>,
646 after: &'a BTreeMap<PathBuf, V>,
647) -> impl Iterator<Item = &'a PathBuf> {
648 let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
649 keys.extend(after.keys());
650 keys.into_iter()
651 .filter(move |key| before.get(*key) != after.get(*key))
652}
653
654#[derive(Default)]
660pub(crate) struct ReadOnlyLeavings {
661 pub(crate) tolerated: Vec<PathBuf>,
665 pub(crate) condemning: Vec<PathBuf>,
667}
668
669pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
673 match (first, second) {
674 (None, None) => None,
675 (Some(one), None) | (None, Some(one)) => Some(one),
676 (Some(first), Some(second)) => Some(format!("{first}; {second}")),
677 }
678}
679
680pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
685 const SHOWN: usize = 5;
686 let mut listed = paths
687 .iter()
688 .take(SHOWN)
689 .map(|path| format!("{:?}", path.as_os_str()))
690 .collect::<Vec<_>>()
691 .join(", ");
692 if paths.len() > SHOWN {
693 listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
694 }
695 listed
696}
697
698#[derive(Default)]
705struct IgnoredArtifacts {
706 new_paths: BTreeSet<PathBuf>,
707 changed_paths: BTreeSet<PathBuf>,
708 removed_paths: BTreeSet<PathBuf>,
709}
710
711impl IgnoredArtifacts {
712 fn left(&mut self, allowed: AllowedArtifacts) {
713 self.new_paths.extend(allowed.present);
714 self.removed_paths.extend(allowed.removed);
715 }
716
717 fn changed(&mut self, allowed: AllowedArtifacts) {
718 self.changed_paths.extend(allowed.present);
719 self.removed_paths.extend(allowed.removed);
720 }
721
722 fn report(&self, cwd: &Path) {
724 let paths: BTreeSet<_> = self
725 .new_paths
726 .iter()
727 .chain(&self.changed_paths)
728 .chain(&self.removed_paths)
729 .cloned()
730 .collect();
731 if !paths.is_empty() {
732 logdim!(
733 "the editing call changed {} ignored file(s) in {}: {}. They are outside \
734 the commit; cleanup checks them separately.",
735 paths.len(),
736 cwd.display(),
737 list_paths(&paths.into_iter().collect::<Vec<_>>())
738 );
739 }
740 }
741}
742
743#[derive(Default)]
747struct AllowedArtifacts {
748 present: Vec<PathBuf>,
749 removed: Vec<PathBuf>,
750}
751
752impl AllowedArtifacts {
753 fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
754 let (present, removed) = paths
755 .into_iter()
756 .partition(|path| after.files.contains_key(path));
757 Self { present, removed }
758 }
759}
760
761fn is_generated_artifact(path: &Path) -> bool {
767 const DIRECTORIES: &[&str] = &[
768 "target",
769 "dist",
770 "node_modules",
771 "__pycache__",
772 ".pytest_cache",
773 ".mypy_cache",
774 ".ruff_cache",
775 ".tox",
776 ".nox",
777 ".venv",
778 "venv",
779 ".gradle",
780 ".build",
781 "DerivedData",
782 ".next",
783 ".nuxt",
784 ".svelte-kit",
785 ".turbo",
786 "coverage",
787 ];
788 path.components().any(|component| {
789 let std::path::Component::Normal(name) = component else {
790 return false;
791 };
792 DIRECTORIES
793 .iter()
794 .any(|directory| name == OsStr::new(directory))
795 })
796}
797
798fn is_desktop_metadata(path: &Path) -> bool {
804 path.file_name() == Some(OsStr::new(".DS_Store"))
805}
806
807fn merge_pr_args<'a>(
808 number: &'a str,
809 expected_head: Option<&'a str>,
810 delete_branch: bool,
811) -> Vec<&'a str> {
812 let mut args = vec!["pr", "merge", number, "--squash"];
813 if delete_branch {
814 args.push("--delete-branch");
815 }
816 if let Some(expected_head) = expected_head {
817 args.extend(["--match-head-commit", expected_head]);
818 }
819 args
820}
821
822fn reconcile_pr_creation(
823 branch: &str,
824 created: Result<String>,
825 found: Result<Option<PrRef>>,
826) -> Result<PrRef> {
827 match (created, found) {
828 (_, Ok(Some(pr))) => Ok(pr),
829 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
830 "PR creation reported success but none was found for {branch}"
831 ))),
832 (Err(create), Ok(None)) => Err(spar_err!(
833 "could not open a PR for {branch}. {}",
834 create.last_line()
835 )),
836 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
837 "PR creation reported success for {branch}, but it could not be verified. {}",
838 check.last_line()
839 ))),
840 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
841 "could not open a PR for {branch}. {} The result could not be verified: {}",
842 create.last_line(),
843 check.last_line()
844 ))),
845 }
846}
847
848fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
849 #[derive(Deserialize)]
850 #[serde(rename_all = "camelCase")]
851 struct Row {
852 number: i64,
853 #[serde(default)]
854 url: String,
855 #[serde(default)]
856 title: String,
857 base_ref_name: String,
858 }
859
860 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
861 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
862 })?;
863 Ok(rows
864 .into_iter()
865 .find(|row| row.base_ref_name == base)
866 .map(|row| PrRef {
867 number: row.number,
868 url: row.url,
869 title: row.title,
870 }))
871}
872
873fn signed(body: &str) -> String {
876 if body.contains(COMMENT_MARKER) {
877 return body.to_string();
878 }
879 format!("{body}\n\n{COMMENT_MARKER}")
880}
881
882pub fn is_spar_comment(body: &str) -> bool {
884 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
885}
886
887#[derive(Debug, Clone, Default, PartialEq, Eq)]
889pub struct ApiResponse {
890 pub status: u16,
891 pub headers: BTreeMap<String, String>,
893 pub body: String,
894}
895
896pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
900 let mut lines = stdout.split_inclusive('\n');
901 let first = lines.next()?.trim_end_matches(['\r', '\n']);
902 if !first.starts_with("HTTP/") {
903 return None;
904 }
905 let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
906 let mut headers = BTreeMap::new();
907 let mut body = String::new();
908 let mut in_body = false;
909 for line in lines {
910 if in_body {
911 body.push_str(line);
912 continue;
913 }
914 let bare = line.trim_end_matches(['\r', '\n']);
915 if bare.is_empty() {
916 in_body = true;
917 continue;
918 }
919 if let Some((name, value)) = bare.split_once(':') {
920 headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
921 }
922 }
923 Some(ApiResponse {
924 status,
925 headers,
926 body,
927 })
928}
929
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 if let Some(parent) = path.parent() {
2163 std::fs::create_dir_all(parent)
2164 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2165 }
2166 if !self.remove_review_worktree_at(&path)? {
2171 bail!(
2172 "the existing review worktree for PR #{number} could not be removed safely. Its \
2173 reference was kept."
2174 );
2175 }
2176
2177 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2178 spar_err!(
2179 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2180 every pull request, so this usually means the number is wrong or `origin` does \
2181 not point at the repository the PR is on.",
2182 e.last_line()
2183 )
2184 })?;
2185
2186 let path_str = path.display().to_string();
2187 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2188 Ok(path)
2189 }
2190
2191 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2192 let path = self.worktree_path(&format!("review-{number}"));
2193 if !path.is_dir() {
2194 return Ok(());
2195 }
2196 let local_ref = review_ref(number);
2197 if !self.worktree_belongs_to_repo(&path)? {
2198 return Ok(());
2199 }
2200 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2201 bail!(
2202 "the existing review worktree for PR #{number} has no recorded head at \
2203 {local_ref}. Refusing to rebuild {}.",
2204 path.display()
2205 );
2206 }
2207 let worktree_head = self.head_oid_checked(&path)?;
2208 let recorded_head = self
2209 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2210 .trim()
2211 .to_string();
2212 if worktree_head != recorded_head {
2213 bail!(
2214 "the existing review worktree for PR #{number} has a local commit that is not on \
2215 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2216 {} before retrying.",
2217 path.display()
2218 );
2219 }
2220 self.report_recovery_markers(&path);
2221 self.refuse_dirty_worktree(
2222 &path,
2223 &format!("review worktree for PR #{number}"),
2224 Disposable::ReviewOutput,
2225 )?;
2226 Ok(())
2227 }
2228
2229 fn report_recovery_markers(&self, path: &Path) {
2237 let Ok(entries) = std::fs::read_dir(path) else {
2238 return;
2239 };
2240 for entry in entries.flatten() {
2241 let name = entry.file_name();
2242 let Some(name) = name.to_str() else { continue };
2243 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2244 continue;
2245 }
2246 match std::fs::read_to_string(entry.path()) {
2247 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2248 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2249 }
2250 }
2251 }
2252
2253 pub fn worktree_for_split(
2266 &self,
2267 parent: i64,
2268 index: usize,
2269 start: &str,
2270 ) -> Result<(PathBuf, String)> {
2271 let slot = self.free_split_slot(parent, index)?;
2272 let branch = format!("{}{slot}", self.branch_prefix);
2273 let path = self.worktree_path(&slot);
2274
2275 if let Some(dir) = path.parent() {
2276 std::fs::create_dir_all(dir)
2277 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2278 }
2279 self.refuse_dirty_worktree(
2280 &path,
2281 &format!("worktree for part {index} of PR #{parent}"),
2282 Disposable::BuildOutput,
2283 )?;
2284 if !self.remove_worktree_at(&path)? {
2288 bail!(
2289 "the existing worktree for part {index} of PR #{parent} could not be removed \
2290 safely. No branch was created."
2291 );
2292 }
2293
2294 let path_str = path.display().to_string();
2295 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2296 .map_err(|e| {
2297 spar_err!(
2298 "could not create a worktree for part {index} of #{parent}. {}",
2299 e.last_line()
2300 )
2301 })?;
2302 self.record_branch(&branch, "split", parent);
2305 Ok((path, branch))
2306 }
2307
2308 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2315 for attempt in 1..=SPLIT_SLOTS {
2316 let slot = split_slot(parent, index, attempt);
2317 let branch = format!("{}{slot}", self.branch_prefix);
2318 self.git_try(&["fetch", "origin", &branch]);
2319 if !self.rev_exists(&self.root, &branch)
2320 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2321 {
2322 return Ok(slot);
2323 }
2324 }
2325 bail!(
2326 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2327 names are all taken. Inspect the existing branches and child pull requests. Finish \
2328 recording the earlier split, or remove every retained local worktree and branch, \
2329 child pull request, and remote split branch before starting over.",
2330 self.branch_for_split(parent, index)
2331 )
2332 }
2333
2334 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2340 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2341 Ok(!self
2342 .git(&["ls-remote", "--heads", "origin", &pattern])?
2343 .trim()
2344 .is_empty())
2345 }
2346
2347 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2354 match self.branch_deletion_is_safe(branch) {
2355 Ok(true) => {}
2356 Ok(false) => {
2357 logdim!(
2358 "kept {branch} and {} because no surviving ref preserves its tip",
2359 dir.display()
2360 );
2361 return;
2362 }
2363 Err(error) => {
2364 logdim!(
2365 "kept {branch} and {} because preservation could not be verified: {}",
2366 dir.display(),
2367 error.last_line()
2368 );
2369 return;
2370 }
2371 }
2372 match self.remove_worktree_at(dir) {
2373 Ok(true) => match self.delete_branch_if_safe(branch) {
2374 Ok(true) => self.forget_branch(branch),
2375 Ok(false) => {
2376 logdim!("kept {branch} because its tip or reflog changed before deletion")
2377 }
2378 Err(error) => logdim!(
2379 "kept {branch} because deletion safety could not be rechecked: {}",
2380 error.last_line()
2381 ),
2382 },
2383 Ok(false) => {}
2384 Err(error) => logdim!(
2385 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2386 dir.display(),
2387 error.last_line()
2388 ),
2389 }
2390 }
2391
2392 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2398 let record = self.known_branches().get(branch).cloned();
2399 if record.is_none_or(|record| record.kind != "split") {
2400 logdim!("kept {branch} because no split branch record proves ownership");
2401 return false;
2402 }
2403 let local_ref = format!("refs/heads/{branch}");
2404 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2405 Ok(value) => value.trim().to_string(),
2406 Err(error) => {
2407 logdim!(
2408 "kept {branch} because its tip could not be checked: {}",
2409 error.last_line()
2410 );
2411 return false;
2412 }
2413 };
2414 if expected != disposable_head {
2415 logdim!("kept {branch} because it moved beyond the disposable slice");
2416 return false;
2417 }
2418 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2419 Ok(true) => {}
2420 Ok(false) => {
2421 logdim!(
2422 "kept {branch} because its reflog contains work outside the disposable slice"
2423 );
2424 return false;
2425 }
2426 Err(error) => {
2427 logdim!(
2428 "kept {branch} because its reflog could not be checked: {}",
2429 error.last_line()
2430 );
2431 return false;
2432 }
2433 }
2434 match self.head_oid_checked(dir) {
2435 Ok(head) if head == disposable_head => {}
2436 Ok(_) => {
2437 logdim!(
2438 "kept {branch} and {} because the worktree moved beyond the disposable slice",
2439 dir.display()
2440 );
2441 return false;
2442 }
2443 Err(error) => {
2444 logdim!(
2445 "kept {branch} and {} because its head could not be checked: {}",
2446 dir.display(),
2447 error.last_line()
2448 );
2449 return false;
2450 }
2451 }
2452 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2453 Ok(true) => {}
2454 Ok(false) => return false,
2455 Err(error) => {
2456 logdim!(
2457 "kept {branch} and {} because the disposable slice could not be verified: {}",
2458 dir.display(),
2459 error.last_line()
2460 );
2461 return false;
2462 }
2463 }
2464 if let Err(error) =
2465 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2466 {
2467 logdim!(
2468 "kept {branch} because its exact disposable tip could not be deleted: {}",
2469 error.last_line()
2470 );
2471 return false;
2472 }
2473 match self.exact_ref_exists_checked(&self.root, &local_ref) {
2474 Ok(false) => {
2475 self.forget_branch(branch);
2476 true
2477 }
2478 Ok(true) => {
2479 logdim!("kept {branch} because its ref still exists after deletion");
2480 false
2481 }
2482 Err(error) => {
2483 logdim!(
2484 "kept the branch record for {branch} because deletion could not be verified: {}",
2485 error.last_line()
2486 );
2487 false
2488 }
2489 }
2490 }
2491
2492 pub fn release_review_worktree(&self, number: i64) {
2493 let path = self.worktree_path(&format!("review-{number}"));
2494 match self.review_ref_deletion_is_safe(number) {
2495 Ok(true) => {}
2496 Ok(false) => {
2497 logdim!(
2498 "kept {} because no surviving ref preserves its review history",
2499 path.display()
2500 );
2501 return;
2502 }
2503 Err(error) => {
2504 logdim!(
2505 "kept {} because review history could not be verified: {}",
2506 path.display(),
2507 error.last_line()
2508 );
2509 return;
2510 }
2511 }
2512 match self.remove_worktree_at(&path) {
2513 Ok(true) => match self.delete_review_ref_if_safe(number) {
2514 Ok(true) => {}
2515 Ok(false) => logdim!(
2516 "kept {} because its review history changed before deletion",
2517 review_ref(number)
2518 ),
2519 Err(error) => logdim!(
2520 "kept {} because deletion safety could not be rechecked: {}",
2521 review_ref(number),
2522 error.last_line()
2523 ),
2524 },
2525 Ok(false) => {}
2526 Err(error) => logdim!(
2527 "kept {} because removal did not reach a confirmed quiet point: {}",
2528 path.display(),
2529 error.last_line()
2530 ),
2531 }
2532 }
2533
2534 pub(crate) fn release_review_worktree_checked(
2537 &self,
2538 number: i64,
2539 checkpoint: &WorktreeCheckpoint,
2540 ) -> Result<()> {
2541 let path = self.worktree_path(&format!("review-{number}"));
2542 self.require_unchanged_worktree(
2543 &path,
2544 checkpoint,
2545 &format!("review worktree for PR #{number}"),
2546 )?;
2547 if !self.review_ref_deletion_is_safe(number)? {
2548 bail!(
2549 "the review reference for PR #{number} has reflog-only recovery history. The \
2550 worktree and reference were kept."
2551 );
2552 }
2553 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2554 bail!(
2555 "the verified review worktree at {} could not be removed, so its reference was \
2556 kept",
2557 path.display()
2558 );
2559 }
2560 if !self.delete_review_ref_if_safe(number)? {
2561 bail!(
2562 "the review reference for PR #{number} changed before deletion. The reference was \
2563 kept."
2564 );
2565 }
2566 Ok(())
2567 }
2568
2569 pub fn release_pr_worktree(&self, number: i64) -> bool {
2570 let path = self.worktree_path(&format!("pr-{number}"));
2571 let local = self.branch_for_pr(number);
2572 match self.branch_deletion_is_safe(&local) {
2573 Ok(true) => {}
2574 Ok(false) => {
2575 logdim!(
2576 "kept {local} and {} because no surviving ref preserves its tip",
2577 path.display()
2578 );
2579 return false;
2580 }
2581 Err(error) => {
2582 logdim!(
2583 "kept {local} and {} because preservation could not be verified: {}",
2584 path.display(),
2585 error.last_line()
2586 );
2587 return false;
2588 }
2589 }
2590 match self.remove_worktree_at(&path) {
2591 Ok(true) => match self.delete_branch_if_safe(&local) {
2592 Ok(true) => {
2593 self.forget_branch(&local);
2594 true
2595 }
2596 Ok(false) => {
2597 logdim!("kept {local} because its tip or reflog changed before deletion");
2598 false
2599 }
2600 Err(error) => {
2601 logdim!(
2602 "kept {local} because deletion safety could not be rechecked: {}",
2603 error.last_line()
2604 );
2605 false
2606 }
2607 },
2608 Ok(false) => false,
2609 Err(error) => {
2610 logdim!(
2611 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
2612 path.display(),
2613 error.last_line()
2614 );
2615 false
2616 }
2617 }
2618 }
2619
2620 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
2631 let remote = format!("origin/{base}");
2632 if self.rev_exists(cwd, &remote) {
2633 return remote;
2634 }
2635 if self.rev_exists(cwd, base) {
2636 logdim!("origin/{base} does not resolve, comparing against local {base}");
2637 return base.to_string();
2638 }
2639 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
2640 remote
2641 }
2642
2643 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
2644 let spec = format!("{refname}^{{commit}}");
2645 !self
2646 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
2647 .trim()
2648 .is_empty()
2649 }
2650
2651 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2652 let range = format!("{}..HEAD", self.base_ref(cwd, base));
2653 !self
2654 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2655 .trim()
2656 .is_empty()
2657 }
2658
2659 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2660 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2661 Ok(found.lines().any(|line| line.trim() == refname))
2662 }
2663
2664 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2665 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2666 count.trim().parse::<usize>().map_err(|e| {
2667 spar_err!(
2668 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2669 count.trim()
2670 )
2671 })
2672 }
2673
2674 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2675 let remote = format!("refs/remotes/origin/{base}");
2676 if self.exact_ref_exists_checked(cwd, &remote)? {
2677 return Ok(remote);
2678 }
2679 let local = format!("refs/heads/{base}");
2680 if self.exact_ref_exists_checked(cwd, &local)? {
2681 return Ok(local);
2682 }
2683 bail!("neither origin/{base} nor local branch {base} resolves")
2684 }
2685
2686 pub(crate) fn commit_count_checked(
2687 &self,
2688 cwd: &Path,
2689 refname: &str,
2690 base: &str,
2691 ) -> Result<usize> {
2692 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
2693 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
2694 count.trim().parse::<usize>().map_err(|e| {
2695 spar_err!(
2696 "git returned an invalid commit count for {range}: {:?} ({e})",
2697 count.trim()
2698 )
2699 })
2700 }
2701
2702 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
2703 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
2704 }
2705
2706 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
2707 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
2708 let head = head.trim().to_string();
2709 if head.is_empty() {
2710 bail!("git returned an empty HEAD for {}", cwd.display());
2711 }
2712 Ok(head)
2713 }
2714
2715 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
2720 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
2721 self.local_branch_is_preserved(branch.trim())
2722 }
2723
2724 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
2727 let known = self.known_branches();
2728 let Some(record) = known.get(branch) else {
2729 return Ok(false);
2730 };
2731 self.branch_is_preserved_checked(branch, record)
2732 }
2733
2734 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
2736 has_uncommitted_work(cwd)
2737 }
2738
2739 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
2742 repository_has_recoverable_work_with(cwd, true, disposable)
2743 }
2744
2745 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2747 let attributes = attribute_state(cwd)?;
2748 Ok(WorktreeBaseline {
2749 attributes,
2750 ignored_untracked: ignored_untracked_state(cwd)?,
2751 git_state: safe_git_state(cwd)?,
2752 })
2753 }
2754
2755 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2758 let attributes = attribute_state(cwd)?;
2759 Ok(WorktreeCheckpoint {
2760 path: std::fs::canonicalize(cwd)
2761 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2762 attributes,
2763 git_state: safe_git_state(cwd)?,
2764 ignored_untracked: ignored_untracked_state(cwd)?,
2765 })
2766 }
2767
2768 pub(crate) fn require_unchanged_worktree(
2771 &self,
2772 cwd: &Path,
2773 checkpoint: &WorktreeCheckpoint,
2774 label: &str,
2775 ) -> Result<()> {
2776 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2777 crate::error::SparError::uncertain_write(format!(
2778 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2779 cwd.display()
2780 ))
2781 })?;
2782 if resolved != checkpoint.path {
2783 return Err(uncertain_worktree_change(
2784 cwd,
2785 format!(
2786 "the {label} moved from {} to {} during inspection. It was kept.",
2787 checkpoint.path.display(),
2788 resolved.display()
2789 ),
2790 ));
2791 }
2792 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
2793 uncertain_worktree_change(
2794 cwd,
2795 format!(
2796 "could not verify attribute files in the {label} at {}: {}. It was kept.",
2797 cwd.display(),
2798 e.last_line()
2799 ),
2800 )
2801 })?;
2802 if attributes != checkpoint.attributes {
2803 return Err(uncertain_worktree_change(
2804 cwd,
2805 format!(
2806 "attribute files in the {label} at {} changed during inspection. It was \
2807 kept for recovery.",
2808 cwd.display()
2809 ),
2810 ));
2811 }
2812 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
2813 uncertain_worktree_change(
2814 cwd,
2815 format!(
2816 "could not verify the Git state of the {label} at {}: {}. It was kept.",
2817 cwd.display(),
2818 e.last_line()
2819 ),
2820 )
2821 })?;
2822 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
2823 uncertain_worktree_change(
2824 cwd,
2825 format!(
2826 "could not verify untracked files in the {label} at {}: {}. It was kept.",
2827 cwd.display(),
2828 e.last_line()
2829 ),
2830 )
2831 })?;
2832 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
2833 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
2837 let tracked = checkpoint.git_state.describe_difference(&git_state);
2838 let untracked = (!leavings.condemning.is_empty()).then(|| {
2839 format!(
2840 "untracked file(s) changed: {}",
2841 list_paths(&leavings.condemning)
2842 )
2843 });
2844 let Some(reason) = join_reasons(tracked, untracked) else {
2845 return Ok(());
2846 };
2847 Err(uncertain_worktree_change(
2848 cwd,
2849 format!(
2850 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
2851 for recovery.",
2852 cwd.display()
2853 ),
2854 ))
2855 }
2856
2857 pub(crate) fn refuse_new_ignored_files(
2861 &self,
2862 cwd: &Path,
2863 baseline: &WorktreeBaseline,
2864 ) -> Result<()> {
2865 self.allow_ignored_files(cwd, baseline).map(drop)
2866 }
2867
2868 pub(crate) fn refuse_failed_edit_leavings(
2870 &self,
2871 cwd: &Path,
2872 baseline: &WorktreeBaseline,
2873 ) -> Result<()> {
2874 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
2875 .map(drop)
2876 }
2877
2878 pub(crate) fn refuse_read_only_leavings(
2880 &self,
2881 cwd: &Path,
2882 baseline: &WorktreeBaseline,
2883 ) -> Result<()> {
2884 self.refuse_changed_attributes(cwd, baseline)?;
2885 let after = ignored_untracked_state(cwd).map_err(|e| {
2886 uncertain_worktree_change(
2887 cwd,
2888 format!(
2889 "could not verify untracked files in {} after a read-only call: {}. The \
2890 worktree was kept for recovery.",
2891 cwd.display(),
2892 e.last_line()
2893 ),
2894 )
2895 })?;
2896 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
2897 if leavings.condemning.is_empty() {
2898 return Ok(());
2899 }
2900 Err(uncertain_worktree_change(
2901 cwd,
2902 format!(
2903 "the read-only call left untracked file(s) in {} that the project does not \
2904 ignore: {}. The worktree was kept for recovery.",
2905 cwd.display(),
2906 list_paths(&leavings.condemning)
2907 ),
2908 ))
2909 }
2910
2911 fn allow_ignored_files(
2914 &self,
2915 cwd: &Path,
2916 baseline: &WorktreeBaseline,
2917 ) -> Result<AllowedArtifacts> {
2918 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
2919 }
2920
2921 fn check_new_ignored_files(
2922 &self,
2923 cwd: &Path,
2924 baseline: &WorktreeBaseline,
2925 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
2926 ) -> Result<AllowedArtifacts> {
2927 self.refuse_changed_attributes(cwd, baseline)?;
2928 let after = ignored_untracked_state(cwd).map_err(|e| {
2929 uncertain_worktree_change(
2930 cwd,
2931 format!(
2932 "could not verify untracked files in {} after editing: {}. The worktree was \
2933 kept for recovery.",
2934 cwd.display(),
2935 e.last_line()
2936 ),
2937 )
2938 })?;
2939 let changed = baseline.ignored_untracked.changed_paths(&after);
2940 if changed.is_empty() {
2941 return Ok(AllowedArtifacts::default());
2942 }
2943 let (ignored, changed): (Vec<_>, Vec<_>) = changed
2944 .into_iter()
2945 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
2946 if changed.is_empty() {
2947 return Ok(AllowedArtifacts::split(ignored, &after));
2948 }
2949 let listed = list_paths(&changed);
2950 Err(uncertain_worktree_change(
2951 cwd,
2952 format!(
2953 "the editing call created or changed untracked or ignored file(s) in {} that \
2954 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2955 recovery.",
2956 cwd.display()
2957 ),
2958 ))
2959 }
2960
2961 pub(crate) fn refuse_changed_existing_untracked(
2966 &self,
2967 cwd: &Path,
2968 baseline: &WorktreeBaseline,
2969 ) -> Result<()> {
2970 self.check_changed_existing_untracked(cwd, baseline)
2971 .map(drop)
2972 }
2973
2974 fn allow_changed_ignored_artifacts(
2975 &self,
2976 cwd: &Path,
2977 baseline: &WorktreeBaseline,
2978 ) -> Result<AllowedArtifacts> {
2979 self.check_changed_existing_untracked(cwd, baseline)
2980 }
2981
2982 fn check_changed_existing_untracked(
2983 &self,
2984 cwd: &Path,
2985 baseline: &WorktreeBaseline,
2986 ) -> Result<AllowedArtifacts> {
2987 self.refuse_changed_attributes(cwd, baseline)?;
2988 let after = ignored_untracked_state(cwd).map_err(|e| {
2989 uncertain_worktree_change(
2990 cwd,
2991 format!(
2992 "could not verify existing untracked files in {} after editing: {}. The \
2993 worktree was kept for recovery.",
2994 cwd.display(),
2995 e.last_line()
2996 ),
2997 )
2998 })?;
2999 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3000 if changed.is_empty() {
3001 return Ok(AllowedArtifacts::default());
3002 }
3003 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3004 .into_iter()
3005 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3006 if changed.is_empty() {
3007 return Ok(AllowedArtifacts::split(ignored, &after));
3008 }
3009 let listed = list_paths(&changed);
3010 Err(uncertain_worktree_change(
3011 cwd,
3012 format!(
3013 "the editing call changed or deleted existing untracked file(s) in {}: \
3014 {listed}. The worktree was kept for recovery.",
3015 cwd.display()
3016 ),
3017 ))
3018 }
3019
3020 pub(crate) fn refuse_unrepresented_tracked_changes(
3027 &self,
3028 cwd: &Path,
3029 baseline: &WorktreeBaseline,
3030 ) -> Result<()> {
3031 self.refuse_changed_attributes(cwd, baseline)?;
3032 let after = safe_git_state(cwd).map_err(|e| {
3033 uncertain_worktree_change(
3034 cwd,
3035 format!(
3036 "could not verify tracked files in {} after editing: {}. The worktree was \
3037 kept for recovery.",
3038 cwd.display(),
3039 e.last_line()
3040 ),
3041 )
3042 })?;
3043 let mut changed = Vec::new();
3044 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3045 uncertain_worktree_change(
3046 cwd,
3047 format!(
3048 "could not record untracked files before verifying transformed content in {}: \
3049 {}. The worktree was kept for recovery.",
3050 cwd.display(),
3051 e.last_line()
3052 ),
3053 )
3054 })?;
3055 let mut filter_was_run = false;
3056 let mut filter_problem = None;
3057 let mut repositories: BTreeSet<PathBuf> =
3058 baseline.git_state.repositories.keys().cloned().collect();
3059 repositories.extend(after.repositories.keys().cloned());
3060 'repositories: for repository_path in repositories {
3061 let before_repository = baseline.git_state.repositories.get(&repository_path);
3062 let after_repository = after.repositories.get(&repository_path);
3063 if before_repository.is_none() || after_repository.is_none() {
3064 changed.push(repository_path.clone());
3065 continue;
3066 }
3067 if before_repository.map(|repository| &repository.gitlinks)
3068 != after_repository.map(|repository| &repository.gitlinks)
3069 {
3070 changed.push(repository_path.join("<gitlinks>"));
3071 }
3072 let mut paths = BTreeSet::new();
3073 if let Some(repository) = before_repository {
3074 paths.extend(repository.tracked.keys().cloned());
3075 }
3076 if let Some(repository) = after_repository {
3077 paths.extend(repository.tracked.keys().cloned());
3078 }
3079 for path in paths {
3080 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3081 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3082 let worktree_changed =
3083 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3084 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3085 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3086 if !worktree_changed {
3087 continue;
3088 }
3089 if !index_changed {
3090 changed.push(repository_path.join(&path));
3091 continue;
3092 }
3093 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3094 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3095 let Some(current_entry) = current else {
3096 continue;
3097 };
3098 let Some(current_worktree) = current_worktree else {
3099 continue;
3100 };
3101 let content_changed =
3102 before_worktree.map(|file| file.content) != Some(current_worktree.content);
3103 let mode_changed = before_worktree.map(|file| file.mode.as_str())
3104 != Some(current_worktree.mode.as_str());
3105 let repository = cwd.join(&repository_path);
3106 let represented_content = if content_changed {
3107 filter_was_run = true;
3108 let result =
3109 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
3110 self.refuse_changed_attributes(cwd, baseline)?;
3111 match result {
3112 Ok(expected) => expected == current_worktree.content,
3113 Err(error) => {
3114 filter_problem = Some(format!(
3115 "could not verify transformed content for {:?}: {}",
3116 repository_path.join(&path),
3117 error.last_line()
3118 ));
3119 false
3120 }
3121 }
3122 } else {
3123 true
3124 };
3125 let represented_mode =
3126 !mode_changed || current_worktree.mode == current_entry.index_mode;
3127 if !represented_content || !represented_mode {
3128 changed.push(repository_path.join(&path));
3129 }
3130 if filter_problem.is_some() {
3131 break 'repositories;
3132 }
3133 }
3134 }
3135 if filter_was_run {
3136 self.refuse_changed_attributes(cwd, baseline)?;
3137 let verified = safe_git_state(cwd).map_err(|e| {
3138 uncertain_worktree_change(
3139 cwd,
3140 format!(
3141 "could not recheck tracked files after verifying transformed content in \
3142 {}: {}. The worktree was kept for recovery.",
3143 cwd.display(),
3144 e.last_line()
3145 ),
3146 )
3147 })?;
3148 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3149 uncertain_worktree_change(
3150 cwd,
3151 format!(
3152 "could not recheck untracked files after verifying transformed content \
3153 in {}: {}. The worktree was kept for recovery.",
3154 cwd.display(),
3155 e.last_line()
3156 ),
3157 )
3158 })?;
3159 if verified != after
3160 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3161 {
3162 return Err(uncertain_worktree_change(
3163 cwd,
3164 "a content filter changed the worktree while SPAR verified the managed \
3165 commit. The worktree was kept for recovery.",
3166 ));
3167 }
3168 self.refuse_changed_existing_untracked(cwd, baseline)?;
3169 }
3170 if let Some(problem) = filter_problem {
3171 return Err(uncertain_worktree_change(
3172 cwd,
3173 format!("{problem}. The worktree was kept for recovery."),
3174 ));
3175 }
3176 if changed.is_empty() {
3177 return Ok(());
3178 }
3179 let listed = list_paths(&changed);
3180 Err(uncertain_worktree_change(
3181 cwd,
3182 format!(
3183 "the editing call changed tracked working-file bytes, modes, repositories, or \
3184 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3185 recovery."
3186 ),
3187 ))
3188 }
3189
3190 pub(crate) fn refuse_changed_attributes(
3191 &self,
3192 cwd: &Path,
3193 baseline: &WorktreeBaseline,
3194 ) -> Result<()> {
3195 let after = attribute_state(cwd).map_err(|e| {
3196 uncertain_worktree_change(
3197 cwd,
3198 format!(
3199 "could not verify attribute files in {} after editing: {}. The worktree was \
3200 kept for recovery.",
3201 cwd.display(),
3202 e.last_line()
3203 ),
3204 )
3205 })?;
3206 if after == baseline.attributes {
3207 return Ok(());
3208 }
3209 Err(uncertain_worktree_change(
3210 cwd,
3211 format!(
3212 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3213 refused to run a Git operation that could select a new external filter.",
3214 cwd.display()
3215 ),
3216 ))
3217 }
3218
3219 pub(crate) fn commit_pending_changes(
3224 &self,
3225 cwd: &Path,
3226 baseline: &WorktreeBaseline,
3227 preferred_subject: &str,
3228 fallback_subject: &str,
3229 ) -> Result<bool> {
3230 let mut artifacts = IgnoredArtifacts::default();
3231 self.refuse_changed_attributes(cwd, baseline)?;
3232 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3233 refuse_unsafe_index_flags(cwd)?;
3234 if !self.has_uncommitted_changes(cwd)? {
3235 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3236 artifacts.report(cwd);
3237 return Ok(false);
3238 }
3239 self.stage_managed_changes(cwd, baseline).map_err(|e| {
3240 e.with_message(format!(
3241 "could not stage changes in {}: {}",
3242 cwd.display(),
3243 e.last_line()
3244 ))
3245 })?;
3246 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3249 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3250 if !changed_gitlinks.is_empty() {
3251 let listed = list_paths(&changed_gitlinks);
3252 bail!(
3253 "the editing call added or changed a gitlink at {listed}. It was staged but not \
3254 committed because the referenced repository objects might exist only inside \
3255 this worktree. The worktree was kept for recovery."
3256 );
3257 }
3258 let mut subject = self.clean_title(preferred_subject)?;
3259 if subject.trim().is_empty() {
3260 subject = self.clean_title(fallback_subject)?;
3261 }
3262 self.commit_staged_changes(cwd, &subject).map_err(|e| {
3263 e.with_message(format!(
3264 "could not commit changes in {}: {}. The staged files were kept.",
3265 cwd.display(),
3266 e.last_line()
3267 ))
3268 })?;
3269 if has_tracked_or_staged_work(cwd)? {
3270 bail!(
3271 "the commit in {} left additional uncommitted files. They were kept for \
3272 recovery.",
3273 cwd.display()
3274 );
3275 }
3276 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3277 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3278 artifacts.report(cwd);
3279 Ok(true)
3280 }
3281
3282 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3283 let after = ignored_untracked_state(cwd)?;
3284 self.git_at_without_automation(cwd, &["add", "-u"])?;
3285 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3286 if paths.is_empty() {
3287 return Ok(());
3288 }
3289 let mut input = Vec::new();
3290 for path in paths {
3291 input.extend(os_str_bytes(path.as_os_str())?);
3292 input.push(0);
3293 }
3294 let argv = git_without_automation_argv(&[
3295 "--literal-pathspecs",
3296 "add",
3297 "--pathspec-from-file=-",
3298 "--pathspec-file-nul",
3299 ]);
3300 proc::run_with_input_bytes(
3301 &argv,
3302 &self.git_opts(Some(cwd), true).stop_descendants(true),
3303 &input,
3304 )?;
3305 Ok(())
3306 }
3307
3308 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3311 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3312 .map(|_| ())
3313 }
3314
3315 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3322 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3323 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3324 .trim()
3325 .parse()
3326 .unwrap_or(0)
3327 }
3328
3329 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3333 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3334 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3335 .lines()
3336 .map(str::to_string)
3337 .collect()
3338 }
3339
3340 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3355 let ancestor = self
3356 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3357 .is_ok();
3358 if !ancestor {
3359 return None;
3360 }
3361 let range = format!("{earlier}..{later}");
3362 Some(
3363 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3364 .lines()
3365 .map(str::to_string)
3366 .collect(),
3367 )
3368 }
3369
3370 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3373 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3374 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3375 .lines()
3376 .map(str::trim)
3377 .filter(|line| !line.is_empty())
3378 .map(str::to_string)
3379 .collect()
3380 }
3381
3382 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3398 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3399 self.git_try_at(
3400 Some(cwd),
3401 &["diff", "--name-only", "--no-renames", "-z", &range],
3402 )
3403 .split('\0')
3404 .filter(|path| !path.is_empty())
3405 .map(str::to_string)
3406 .collect()
3407 }
3408
3409 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3412 self.git_try_at(
3413 Some(cwd),
3414 &[
3415 "diff",
3416 "--name-only",
3417 "--no-renames",
3418 "-z",
3419 &format!("{from}..{to}"),
3420 ],
3421 )
3422 .split('\0')
3423 .filter(|path| !path.is_empty())
3424 .map(str::to_string)
3425 .collect()
3426 }
3427
3428 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3431 let base_ref = self.base_ref(cwd, base);
3432 let out = self
3433 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3434 .map_err(|e| {
3435 spar_err!(
3436 "could not find where {refname} and {base_ref} diverged. {}",
3437 e.last_line()
3438 )
3439 })?;
3440 let sha = out.trim().to_string();
3441 if sha.is_empty() {
3442 bail!("{refname} and {base_ref} share no history");
3443 }
3444 Ok(sha)
3445 }
3446
3447 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3448 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3449 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3450 full.trim().to_string()
3451 }
3452
3453 pub fn rewrite_commits_if_needed(
3466 &self,
3467 cwd: &Path,
3468 base: &str,
3469 floor: Option<&str>,
3470 ) -> Result<()> {
3471 let base_ref = self.base_ref(cwd, base);
3472 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3473 self.report_commits_left_alone(cwd, &base_ref, floor);
3474
3475 let range = format!("{floor}..HEAD");
3476 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3477
3478 let offenders = raw
3479 .split('\x1e')
3480 .filter_map(|entry| entry.split_once('\0'))
3481 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3482 .count();
3483 if offenders == 0 {
3484 return Ok(());
3485 }
3486 logdim!("{offenders} commit message(s) violated style rules, rewriting");
3487
3488 let exe = self_binary()?;
3489 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3490
3491 let argv: Vec<String> = [
3492 "git",
3493 "filter-branch",
3494 "-f",
3495 "--msg-filter",
3496 &filter,
3497 &range,
3498 ]
3499 .iter()
3500 .map(|s| s.to_string())
3501 .collect();
3502 let opts = ExecOpts::new()
3503 .cwd(cwd)
3504 .check(false)
3505 .timeout_secs(600)
3506 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3507 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3508 .env(
3509 "SPAR_BAN_AI_ATTRIBUTION",
3510 bool_env(self.style.ban_ai_attribution),
3511 );
3512 let _ = proc::run(&argv, &opts);
3513
3514 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3515 if !style::violations(&after, &self.style).is_empty() {
3516 bail!(
3517 "commit messages still violate style rules after a rewrite in {}.",
3518 cwd.display()
3519 );
3520 }
3521 Ok(())
3522 }
3523
3524 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3529 if floor == base_ref {
3530 return;
3531 }
3532 let raw = self.git_try_at(
3533 Some(cwd),
3534 &[
3535 "log",
3536 &format!("{base_ref}..{floor}"),
3537 "--format=%H%x00%B%x1e",
3538 ],
3539 );
3540 let left: Vec<&str> = raw
3541 .split('\x1e')
3542 .filter_map(|entry| entry.split_once('\0'))
3543 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3544 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3545 .collect();
3546 if left.is_empty() {
3547 return;
3548 }
3549 logdim!(
3550 "{} commit message(s) already on the branch break the style rules and were left \
3551 alone: {}. spar does not rewrite commits it did not write.",
3552 left.len(),
3553 left.join(", ")
3554 );
3555 }
3556
3557 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3572 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3573 if let Some(head) = expected {
3574 self.refuse_push_that_drops(cwd, head)?;
3575 }
3576 let refspec = format!("HEAD:{branch}");
3577 let lease = match expected {
3578 Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
3579 None => "--force-with-lease".to_string(),
3580 };
3581 let pushed = self
3582 .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
3583 .map(|_| ())
3584 .map_err(|e| {
3585 spar_err!(
3586 "could not push to origin/{branch}. {}\nCheck push access and whether the \
3587 branch moved under you.",
3588 e.last_line()
3589 )
3590 });
3591 self.record_write(pushed)
3592 }
3593
3594 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
3602 let head = self.head_oid_checked(cwd)?;
3603 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
3604 return Ok(());
3605 }
3606 bail!(
3607 "refusing to push {head} because it does not contain the reviewed head {expected}. \
3608 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
3609 the intended commits on top of it before resuming.",
3610 cwd.display()
3611 )
3612 }
3613
3614 pub fn push_split_branch(
3623 &self,
3624 cwd: &Path,
3625 branch: &str,
3626 ) -> std::result::Result<(), SplitPushError> {
3627 let remote_ref = format!("refs/heads/{branch}");
3628 let lease = format!("--force-with-lease={remote_ref}:");
3629 let refspec = format!("HEAD:{remote_ref}");
3630 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
3631 Ok(_) => Ok(()),
3632 Err(push_error) => {
3633 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
3634 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
3635 reconcile_failed_split_push(branch, push_error, local, remote)
3636 }
3637 };
3638 self.record_write(result)
3639 }
3640
3641 pub fn gh(&self, args: &[&str]) -> Result<String> {
3644 self.gh_at(None, args)
3645 }
3646
3647 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
3648 let mut argv = vec!["gh".to_string()];
3649 argv.extend(args.iter().map(|s| s.to_string()));
3650 proc::run(
3651 &argv,
3652 &ExecOpts::new()
3653 .cwd(cwd.unwrap_or(&self.root))
3654 .timeout_secs(300),
3655 )
3656 }
3657
3658 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3664 let mut argv = vec!["gh".to_string()];
3665 argv.extend(args.iter().map(|s| s.to_string()));
3666 proc::run(
3667 &argv,
3668 &ExecOpts::new()
3669 .cwd(&self.root)
3670 .timeout_secs(300)
3671 .stdin(stdin),
3672 )
3673 }
3674
3675 pub fn gh_try(&self, args: &[&str]) -> String {
3676 let mut argv = vec!["gh".to_string()];
3677 argv.extend(args.iter().map(|s| s.to_string()));
3678 proc::run(
3679 &argv,
3680 &ExecOpts::new()
3681 .cwd(&self.root)
3682 .check(false)
3683 .timeout_secs(300),
3684 )
3685 .unwrap_or_default()
3686 }
3687
3688 pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
3693 let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
3694 for (name, value) in headers {
3695 argv.push("-H".to_string());
3696 argv.push(format!("{name}: {value}"));
3697 }
3698 argv.push(endpoint.to_string());
3699 let out = proc::exec(
3700 &argv,
3701 &ExecOpts::new()
3702 .cwd(&self.root)
3703 .check(false)
3704 .timeout_secs(300),
3705 )?;
3706 parse_api_response(&out.stdout)
3707 .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
3708 }
3709
3710 pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
3712 let text = self.gh(&[
3713 "pr",
3714 "list",
3715 "--state",
3716 "open",
3717 "--limit",
3718 "500",
3719 "--json",
3720 PR_SWEEP_FIELDS,
3721 ])?;
3722 serde_json::from_str(&text)
3723 .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
3724 }
3725
3726 pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
3728 let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
3729 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3730 }
3731
3732 pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
3737 let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
3739 if slug.is_empty() || login.is_empty() {
3740 return None;
3741 }
3742 let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
3743 let resp = self.gh_api_raw(&path, &[]).ok()?;
3744 match resp.status {
3745 200 => {
3746 let body: Value = serde_json::from_str(&resp.body).ok()?;
3747 Some(body.get("state")?.as_str()? == "active")
3748 }
3749 404 => Some(false),
3750 _ => None,
3751 }
3752 }
3753
3754 pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
3756 if from.is_empty() || to.is_empty() {
3757 return None;
3758 }
3759 let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
3760 self.gh_try(&["api", &path, "--jq", ".total_commits"])
3761 .trim()
3762 .parse()
3763 .ok()
3764 }
3765
3766 pub fn repo_name(&self) -> Option<String> {
3768 let name = self.gh_try(&[
3769 "repo",
3770 "view",
3771 "--json",
3772 "nameWithOwner",
3773 "--jq",
3774 ".nameWithOwner",
3775 ]);
3776 let name = name.trim();
3777 (!name.is_empty()).then(|| name.to_string())
3778 }
3779
3780 pub fn listen_state_path(&self) -> PathBuf {
3781 self.root.join(STATE_DIR).join("state").join("listen.json")
3782 }
3783
3784 pub fn listen_lock_path(&self) -> PathBuf {
3785 self.root.join(STATE_DIR).join("state").join("listen.lock")
3786 }
3787
3788 pub fn viewer_login(&self) -> Result<&str> {
3799 if let Some(login) = self.viewer.get() {
3800 return Ok(login);
3801 }
3802 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3803 let login = if !rest.trim().is_empty() {
3804 rest.trim().to_string()
3805 } else {
3806 self.gh(&[
3809 "api",
3810 "graphql",
3811 "-f",
3812 "query={ viewer { login } }",
3813 "--jq",
3814 ".data.viewer.login",
3815 ])
3816 .map_err(|e| {
3817 spar_err!(
3818 "could not find out who `gh` is authenticated as, so spar cannot tell its \
3819 own comments from anybody else's. {}\nRun `gh auth status`.",
3820 e.last_line()
3821 )
3822 })?
3823 .trim()
3824 .to_string()
3825 };
3826 if login.is_empty() {
3827 bail!("`gh` reported an empty login. Run `gh auth status`.");
3828 }
3829 Ok(self.viewer.get_or_init(|| login))
3830 }
3831
3832 pub fn read_issue(&self, number: i64) -> Result<Issue> {
3838 let text = self
3839 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
3840 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3841 serde_json::from_str(&text)
3842 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3843 }
3844
3845 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3846 let mut issues = Vec::new();
3847 for number in numbers {
3848 let text = self
3849 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
3850 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3851 let issue: Issue = serde_json::from_str(&text)
3852 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3853 if issue.is_closed() {
3854 crate::log!("issue #{number} is closed, skipping");
3855 continue;
3856 }
3857 issues.push(issue);
3858 }
3859 if issues.is_empty() {
3860 bail!("no open issues to work on");
3861 }
3862 Ok(issues)
3863 }
3864
3865 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3871 #[derive(Deserialize)]
3872 struct Row {
3873 number: i64,
3874 }
3875 let text = self.gh(&[
3876 kind,
3877 "list",
3878 "--state",
3879 "open",
3880 "--limit",
3881 &FETCH_CEILING.to_string(),
3882 "--json",
3883 "number",
3884 ])?;
3885 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3886 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3887 numbers.sort_unstable();
3888
3889 let noun = if kind == "issue" { "issues" } else { "PRs" };
3890 let found = numbers.len();
3891 if min_number > 0 {
3892 numbers.retain(|n| *n >= min_number);
3893 let skipped = found - numbers.len();
3894 if skipped > 0 {
3895 crate::log!("{skipped} open {noun} below #{min_number} skipped");
3896 }
3897 }
3898 if found >= FETCH_CEILING {
3899 crate::log!(
3900 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3901 considered."
3902 );
3903 }
3904 if numbers.len() > limit {
3905 crate::log!(
3906 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3907 explicitly.",
3908 numbers.len()
3909 );
3910 numbers.truncate(limit);
3911 }
3912 Ok(numbers)
3913 }
3914
3915 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3917 self.open_numbers("issue", limit, min_number)
3918 }
3919
3920 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3921 self.open_numbers("pr", limit, min_number)
3922 }
3923
3924 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3925 self.branch_prs(branch, "open").into_iter().next()
3926 }
3927
3928 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3931 let text = self.gh(&[
3932 "pr",
3933 "list",
3934 "--head",
3935 branch,
3936 "--base",
3937 base,
3938 "--state",
3939 "open",
3940 "--json",
3941 "number,url,title,baseRefName",
3942 ])?;
3943 pr_for_base(&text, branch, base)
3944 }
3945
3946 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3950 self.branch_prs(branch, "all")
3951 }
3952
3953 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3954 let text = self.gh_try(&[
3955 "pr",
3956 "list",
3957 "--head",
3958 branch,
3959 "--state",
3960 state,
3961 "--json",
3962 "number,url,title",
3963 ]);
3964 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3965 }
3966
3967 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3973 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3974 let text = self
3975 .gh(&[
3976 "api",
3977 &path,
3978 "--jq",
3979 "if .pull_request then \"pr\" else \"issue\" end",
3980 ])
3981 .map_err(|e| {
3982 spar_err!(
3983 "no issue or pull request #{number} in this repository. {}",
3984 e.last_line()
3985 )
3986 })?;
3987 match text.trim() {
3988 "pr" => Ok(ItemKind::Pr),
3989 "issue" => Ok(ItemKind::Issue),
3990 other => Err(spar_err!(
3991 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3992 )),
3993 }
3994 }
3995
3996 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4007 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4008 if let Some(pr) = mine {
4009 return Ok(Some(pr));
4010 }
4011 let listed = match self.gh(&[
4012 "pr",
4013 "list",
4014 "--state",
4015 "open",
4016 "--limit",
4017 &FETCH_CEILING.to_string(),
4018 "--json",
4019 "number,url,title,baseRefName,closingIssuesReferences",
4020 ]) {
4021 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4022 spar_err!("could not read the pull request list while looking for #{issue}")
4023 })?,
4024 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4025 };
4026 if listed.len() >= FETCH_CEILING {
4027 logwarn!(
4028 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
4029 );
4030 }
4031 let default = self.default_branch(base);
4032 if !base.eq_ignore_ascii_case(&default) {
4033 logwarn!(
4037 "#{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."
4038 );
4039 }
4040 Ok(pick_linked_pr(&listed, issue))
4041 }
4042
4043 fn pr_for_branch_failing_closed(
4045 &self,
4046 branch: &str,
4047 base: &str,
4048 issue: i64,
4049 ) -> Result<Option<PrRef>> {
4050 match self.try_pr_for_branch(branch, base) {
4051 Ok(found) => Ok(found),
4052 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4053 }
4054 }
4055
4056 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4065 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4066 return Err(spar_err!(
4067 "could not check whether a pull request already exists for #{issue}: {}. \
4068 Refusing to implement over the top of work that may already be open.",
4069 e.last_line()
4070 ));
4071 }
4072 logwarn!(
4073 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4074 whether a pull request already exists for it.",
4075 e.last_line()
4076 );
4077 Ok(())
4078 }
4079
4080 pub fn pr_view(&self, number: i64) -> Result<PrView> {
4081 let text = self.gh(&[
4082 "pr",
4083 "view",
4084 &number.to_string(),
4085 "--json",
4086 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
4087 ])?;
4088 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4089 }
4090
4091 fn try_pr_state(&self, number: i64) -> Result<String> {
4092 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4093 serde_json::from_str::<Value>(text.trim())
4094 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4095 .get("state")
4096 .and_then(Value::as_str)
4097 .map(str::to_string)
4098 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4099 }
4100
4101 pub fn pr_state(&self, number: i64) -> String {
4102 self.try_pr_state(number).unwrap_or_default()
4103 }
4104
4105 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
4107 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
4108 let oid = serde_json::from_str::<Value>(&text)
4109 .ok()
4110 .and_then(|value| {
4111 value
4112 .get("headRefOid")
4113 .and_then(Value::as_str)
4114 .map(str::trim)
4115 .filter(|oid| !oid.is_empty())
4116 .map(str::to_string)
4117 })
4118 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
4119 Ok(oid)
4120 }
4121
4122 pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
4126 let pull = self.pr_head_oid(pr.number)?;
4127 let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
4128 None
4129 } else {
4130 let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
4131 let listed = self
4132 .git(&["ls-remote", "--heads", "origin", &remote_ref])
4133 .map_err(|e| {
4134 spar_err!(
4135 "could not read where {} is on origin: {}",
4136 pr.head_ref_name.trim(),
4137 e.last_line()
4138 )
4139 })?;
4140 remote_head_oid(&listed, &remote_ref)?
4141 };
4142 Ok(LiveHead { pull, branch })
4143 }
4144
4145 pub fn create_pr(
4146 &self,
4147 cwd: &Path,
4148 branch: &str,
4149 base: &str,
4150 title: &str,
4151 body: &str,
4152 ) -> Result<PrRef> {
4153 let title = self.record_failed_write(self.clean_title(title))?;
4154 let body = self.record_failed_write(self.clean(body))?;
4155 let mut argv = vec![
4156 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
4157 ];
4158 if self.drafts != Drafts::Never {
4159 argv.push("--draft");
4160 }
4161 let created = self.gh_at(Some(cwd), &argv);
4162 let found = self.try_pr_for_branch(branch, base);
4163 self.record_write(reconcile_pr_creation(branch, created, found))
4164 }
4165
4166 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
4167 let body = signed(&self.record_failed_write(self.clean(body))?);
4168 let comments = self.record_failed_write(self.try_issue_comments(number))?;
4169 if has_exact_comment(&comments, &body) {
4170 return Ok(());
4171 }
4172 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
4173 let result = match posted {
4174 Ok(_) => Ok(()),
4175 Err(post_error) => {
4176 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4177 }
4178 };
4179 self.record_write(result)
4180 }
4181
4182 fn pr_is_own(&self, number: i64) -> Result<bool> {
4186 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
4187 let author = serde_json::from_str::<Value>(&text)
4188 .ok()
4189 .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
4190 .unwrap_or_default();
4191 let viewer = self.viewer_login()?.to_string();
4192 Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
4193 }
4194
4195 pub fn submit_review(
4208 &self,
4209 number: i64,
4210 verdict: Option<crate::model::ReviewVerdict>,
4211 body: &str,
4212 commit: &str,
4213 ) -> Result<()> {
4214 let own = match verdict {
4215 Some(_) => self.record_failed_write(self.pr_is_own(number))?,
4216 None => false,
4217 };
4218 let text = match (verdict, own) {
4219 (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
4220 _ => body.to_string(),
4221 };
4222 let body = signed(&self.record_failed_write(self.clean(&text))?);
4223 let viewer = self.record_failed_write(self.viewer_login())?.to_string();
4224 let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
4225 if has_viewer_review(&reviews, &viewer, &body, commit) {
4226 return Ok(());
4227 }
4228 let event = match (verdict, own) {
4229 (Some(v), false) => v.api_event(),
4230 _ => "COMMENT",
4231 };
4232 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
4233 let posted = self.gh(&[
4234 "api",
4235 "-X",
4236 "POST",
4237 &path,
4238 "-f",
4239 &format!("commit_id={commit}"),
4240 "-f",
4241 &format!("event={event}"),
4242 "-f",
4243 &format!("body={body}"),
4244 "--silent",
4245 ]);
4246 let result = match posted {
4247 Ok(_) => Ok(()),
4248 Err(post_error) => reconcile_review_post(
4249 number,
4250 &body,
4251 &viewer,
4252 commit,
4253 post_error,
4254 self.try_pr_reviews(number),
4255 ),
4256 };
4257 self.record_write(result)
4258 }
4259
4260 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
4261 let body = signed(&self.record_failed_write(self.clean(body))?);
4262 let comments = self.record_failed_write(self.try_issue_comments(number))?;
4263 if has_exact_comment(&comments, &body) {
4264 return Ok(());
4265 }
4266 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
4267 let result = match posted {
4268 Ok(_) => Ok(()),
4269 Err(post_error) => {
4270 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4271 }
4272 };
4273 self.record_write(result)
4274 }
4275
4276 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
4281 self.comment_issue(number, body)?;
4282 let n = number.to_string();
4283 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
4284 Ok(_) => Ok(()),
4285 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
4287 spar_err!(
4288 "commented on #{number} but could not close it: {}",
4289 e.last_line()
4290 )
4291 }),
4292 };
4293 self.record_write(closed)
4294 }
4295
4296 pub fn edit_issue_body(
4311 &self,
4312 number: i64,
4313 expected: &str,
4314 body: &str,
4315 inserted: &str,
4316 ) -> Result<()> {
4317 let cleaned = self.record_failed_write(self.clean(inserted))?;
4318 if cleaned.trim() != inserted.trim() {
4319 return self.record_failed_write(Err(spar_err!(
4320 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
4321 )));
4322 }
4323 let current = self.record_failed_write(self.issue_body(number))?;
4324 if current != expected {
4325 return self.record_failed_write(Err(spar_err!(
4326 "the body of #{number} changed since it was read, so it was left alone rather \
4327 than written over."
4328 )));
4329 }
4330 let edited = self.gh_stdin(
4331 &["issue", "edit", &number.to_string(), "--body-file", "-"],
4332 body,
4333 );
4334 let result = match edited {
4335 Ok(_) => Ok(()),
4336 Err(edit_error) => {
4337 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
4338 }
4339 };
4340 self.record_write(result)
4341 }
4342
4343 pub fn issue_body(&self, number: i64) -> Result<String> {
4345 #[derive(Deserialize)]
4346 struct Row {
4347 #[serde(default)]
4348 body: Option<String>,
4349 }
4350 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
4351 let row: Row = serde_json::from_str(text.trim())
4352 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4353 Ok(row.body.unwrap_or_default())
4354 }
4355
4356 pub fn open_issue_rows(&self) -> Vec<Issue> {
4361 let text = self.gh_try(&[
4362 "issue",
4363 "list",
4364 "--state",
4365 "open",
4366 "--limit",
4367 &FETCH_CEILING.to_string(),
4368 "--json",
4369 "number,title,body,labels,state,stateReason,url",
4370 ]);
4371 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
4372 }
4373
4374 pub fn open_pr_rows(&self) -> Vec<PrRow> {
4376 let text = self.gh_try(&[
4377 "pr",
4378 "list",
4379 "--state",
4380 "open",
4381 "--limit",
4382 &FETCH_CEILING.to_string(),
4383 "--json",
4384 "number,title,changedFiles,additions,deletions",
4385 ]);
4386 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
4387 }
4388
4389 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
4390 self.create_issue_apart_from(title, body, None)
4391 }
4392
4393 pub fn create_issue_apart_from(
4394 &self,
4395 title: &str,
4396 body: &str,
4397 apart_from: Option<i64>,
4398 ) -> Result<String> {
4399 let title = self.record_failed_write(self.clean_title(title))?;
4400 let body = self.record_failed_write(self.clean_issue_body(body))?;
4401 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
4402 let result = match created {
4403 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
4404 created => {
4405 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
4406 reconcile_issue_creation(&title, created, found)
4407 }
4408 };
4409 self.record_write(result)
4410 }
4411}
4412
4413#[derive(Debug, Clone)]
4415pub struct ExistingIssue {
4416 pub number: i64,
4417 pub url: String,
4418 pub title: String,
4419 pub body: String,
4420 pub open: bool,
4421}
4422
4423impl Repo {
4424 pub(crate) fn try_exact_issue_apart_from(
4425 &self,
4426 title: &str,
4427 body: &str,
4428 apart_from: Option<i64>,
4429 ) -> Result<Option<ExistingIssue>> {
4430 #[derive(Deserialize)]
4431 #[serde(rename_all = "camelCase")]
4432 struct Row {
4433 number: i64,
4434 #[serde(default)]
4435 title: String,
4436 #[serde(default)]
4437 url: String,
4438 #[serde(default)]
4439 body: Option<String>,
4440 #[serde(default)]
4441 state: String,
4442 }
4443
4444 let text = self.gh(&[
4445 "issue",
4446 "list",
4447 "--state",
4448 "all",
4449 "--limit",
4450 "100",
4451 "--json",
4452 "number,title,url,body,state,stateReason",
4453 ])?;
4454 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4455 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
4456 Ok(rows
4457 .into_iter()
4458 .filter(|row| Some(row.number) != apart_from)
4459 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
4460 .map(|row| ExistingIssue {
4461 number: row.number,
4462 url: row.url,
4463 title: row.title,
4464 body: row.body.unwrap_or_default(),
4465 open: row.state.eq_ignore_ascii_case("open"),
4466 }))
4467 }
4468
4469 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
4477 self.find_similar_issue_apart_from(title, body, None)
4478 }
4479
4480 pub fn find_similar_issue_apart_from(
4488 &self,
4489 title: &str,
4490 body: &str,
4491 apart_from: Option<i64>,
4492 ) -> Option<ExistingIssue> {
4493 self.try_similar_issue(title, body, apart_from, true)
4494 .ok()
4495 .flatten()
4496 }
4497
4498 pub fn try_find_similar_issue_apart_from(
4500 &self,
4501 title: &str,
4502 body: &str,
4503 apart_from: Option<i64>,
4504 ) -> Result<Option<ExistingIssue>> {
4505 self.try_similar_issue(title, body, apart_from, false)
4506 }
4507
4508 fn try_similar_issue(
4510 &self,
4511 title: &str,
4512 body: &str,
4513 apart_from: Option<i64>,
4514 include_closed: bool,
4515 ) -> Result<Option<ExistingIssue>> {
4516 #[derive(Deserialize)]
4517 #[serde(rename_all = "camelCase")]
4518 struct Row {
4519 number: i64,
4520 #[serde(default)]
4521 title: String,
4522 #[serde(default)]
4523 url: String,
4524 #[serde(default)]
4525 body: String,
4526 #[serde(default)]
4527 state: String,
4528 }
4529 if title.trim().is_empty() {
4530 return Ok(None);
4531 }
4532 let query: String = title
4535 .chars()
4536 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4537 .take(120)
4538 .collect();
4539 let state = if include_closed { "all" } else { "open" };
4548 let text = self.gh(&[
4549 "issue",
4550 "list",
4551 "--state",
4552 state,
4553 "--limit",
4554 "100",
4555 "--search",
4556 query.trim(),
4557 "--json",
4558 "number,title,url,body,state,stateReason",
4559 ])?;
4560 let rows: Vec<Row> = serde_json::from_str(text.trim())
4561 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
4562
4563 Ok(rows
4564 .into_iter()
4565 .filter(|row| Some(row.number) != apart_from)
4566 .find(|row| {
4567 row.title.trim().eq_ignore_ascii_case(title.trim())
4568 || textsim::covers(title, body, &row.title, &row.body)
4569 })
4570 .map(|row| ExistingIssue {
4571 number: row.number,
4572 url: row.url,
4573 title: row.title,
4574 open: row.state.eq_ignore_ascii_case("open"),
4575 body: row.body,
4576 }))
4577 }
4578
4579 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
4581 #[derive(Deserialize)]
4582 struct Row {
4583 title: String,
4584 url: String,
4585 }
4586 let needle = title.trim().to_lowercase();
4587 if needle.is_empty() {
4588 return None;
4589 }
4590 let query: String = title
4592 .chars()
4593 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4594 .take(120)
4595 .collect();
4596 let text = self.gh_try(&[
4597 "issue",
4598 "list",
4599 "--state",
4600 "all",
4601 "--limit",
4602 "100",
4603 "--search",
4604 query.trim(),
4605 "--json",
4606 "number,title,url",
4607 ]);
4608 serde_json::from_str::<Vec<Row>>(text.trim())
4609 .ok()?
4610 .into_iter()
4611 .find(|row| row.title.trim().to_lowercase() == needle)
4612 .map(|row| row.url)
4613 }
4614
4615 pub fn mark_ready(&self, number: i64) -> bool {
4623 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
4624 Ok(_) => true,
4625 Err(e) => {
4626 logdim!(
4627 "PR #{number} is approved but could not be taken out of draft: {}",
4628 e.last_line()
4629 );
4630 false
4631 }
4632 }
4633 }
4634
4635 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
4637 let listed = self
4638 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
4639 .ok()?;
4640 let wanted = format!("branch refs/heads/{branch}");
4641 let mut current: Option<PathBuf> = None;
4642 for line in listed.lines() {
4643 if let Some(path) = line.strip_prefix("worktree ") {
4644 current = Some(PathBuf::from(path));
4645 } else if line == wanted {
4646 return current;
4647 }
4648 }
4649 None
4650 }
4651
4652 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
4668 let branch = self.pr_view(number).ok()?.head_ref_name;
4669 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
4670 return None;
4671 }
4672 let mut released = None;
4673 if let Some(path) = self.worktree_holding(&branch) {
4674 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
4675 return None;
4676 }
4677 if !matches!(
4678 self.has_recoverable_work(&path, Disposable::BuildOutput),
4679 Ok(false)
4680 ) {
4681 return None;
4682 }
4683 match self.remove_worktree_at(&path) {
4684 Ok(true) => released = Some(path),
4685 _ => return None,
4686 }
4687 }
4688 match self.delete_branch_if_safe(&branch) {
4689 Ok(true) => {
4690 self.forget_branch(&branch);
4691 Some(match released {
4692 Some(path) => format!(
4693 " The worktree at {} was released and branch {branch} deleted.",
4694 path.display()
4695 ),
4696 None => format!(" Branch {branch} was deleted."),
4697 })
4698 }
4699 _ => released.map(|path| {
4700 format!(
4701 " The worktree at {} was released, but branch {branch} was kept.",
4702 path.display()
4703 )
4704 }),
4705 }
4706 }
4707
4708 pub fn merge_pr(&self, number: i64) -> Result<()> {
4712 let n = number.to_string();
4713 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4714 Ok(_) => Ok(()),
4715 Err(e) => {
4716 if self.pr_state(number) == "MERGED" {
4717 let finished = self
4718 .finish_merged_branch_cleanup(number)
4719 .unwrap_or_default();
4720 logdim!(
4721 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4722 e.last_line()
4723 );
4724 Ok(())
4725 } else {
4726 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4727 }
4728 }
4729 };
4730 self.record_write(merged)
4731 }
4732
4733 pub fn merge_pr_at_head(
4735 &self,
4736 number: i64,
4737 expected_head: &str,
4738 delete_branch: bool,
4739 ) -> Result<()> {
4740 let n = number.to_string();
4741 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4742 Ok(_) => Ok(()),
4743 Err(e) => {
4744 if self.pr_state(number) == "MERGED" {
4745 let finished = self
4746 .finish_merged_branch_cleanup(number)
4747 .unwrap_or_default();
4748 logdim!(
4749 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4750 e.last_line()
4751 );
4752 Ok(())
4753 } else {
4754 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4755 }
4756 }
4757 };
4758 self.record_write(merged)
4759 }
4760
4761 pub fn followups_path(&self) -> PathBuf {
4766 self.root.join(STATE_DIR).join("followups.md")
4767 }
4768
4769 pub fn brainstorms_dir(&self) -> PathBuf {
4774 self.root.join(STATE_DIR).join("brainstorms")
4775 }
4776
4777 pub fn worked_followups_path(&self) -> PathBuf {
4785 self.root.join(STATE_DIR).join("followups.done.md")
4786 }
4787
4788 pub fn spend_path(&self) -> PathBuf {
4791 self.root.join(STATE_DIR).join("state").join("spend.json")
4792 }
4793
4794 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4800 if calls.is_empty() {
4801 return Ok(());
4802 }
4803 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4804 .ok()
4805 .and_then(|text| serde_json::from_str(&text).ok())
4806 .unwrap_or_default();
4807 all.extend(calls.iter().cloned());
4808 if let Some(parent) = self.spend_path().parent() {
4809 let _ = std::fs::create_dir_all(parent);
4810 }
4811 write_json_atomic(&self.spend_path(), &all)
4812 }
4813
4814 pub fn implementors_path(&self) -> PathBuf {
4820 self.root
4821 .join(STATE_DIR)
4822 .join("state")
4823 .join("implementors.json")
4824 }
4825
4826 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4827 std::fs::read_to_string(self.implementors_path())
4828 .ok()
4829 .and_then(|text| serde_json::from_str(&text).ok())
4830 .unwrap_or_default()
4831 }
4832
4833 pub fn remember_implementor(&self, issue: i64, agent: &str) {
4836 let mut known = self.read_implementors();
4837 known.insert(issue, agent.to_string());
4838 if let Some(parent) = self.implementors_path().parent() {
4839 let _ = std::fs::create_dir_all(parent);
4840 }
4841 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4842 logdim!("could not record who implemented #{issue}: {e}");
4843 }
4844 }
4845
4846 pub fn contested_path(&self) -> PathBuf {
4851 self.root
4852 .join(STATE_DIR)
4853 .join("state")
4854 .join("contested.json")
4855 }
4856
4857 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4859 std::fs::read_to_string(self.contested_path())
4860 .ok()
4861 .and_then(|text| serde_json::from_str(&text).ok())
4862 .unwrap_or_default()
4863 }
4864
4865 pub fn remember_contested(&self, items: &[ContestedItem]) {
4870 if items.is_empty() {
4871 return;
4872 }
4873 let mut known = self.read_contested();
4874 for item in items {
4875 known.insert(
4876 item.issue,
4877 Contested {
4878 title: item.title.clone(),
4879 positions: item.positions.clone(),
4880 reasons: item.reasons.clone(),
4881 },
4882 );
4883 }
4884 if let Some(parent) = self.contested_path().parent() {
4885 let _ = std::fs::create_dir_all(parent);
4886 }
4887 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4888 logdim!("could not record the contested issues: {e}");
4889 }
4890 }
4891
4892 pub fn forget_contested(&self, numbers: &[i64]) {
4894 let mut known = self.read_contested();
4895 let before = known.len();
4896 known.retain(|number, _| !numbers.contains(number));
4897 if known.len() == before {
4898 return;
4899 }
4900 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4901 logdim!("could not update the contested issues: {e}");
4902 }
4903 }
4904
4905 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4906 self.root
4907 .join(STATE_DIR)
4908 .join("state")
4909 .join(format!("checkin-{number}.json"))
4910 }
4911
4912 pub fn review_record_path(&self, number: i64) -> PathBuf {
4914 self.root
4915 .join(STATE_DIR)
4916 .join("state")
4917 .join(format!("review-{number}.json"))
4918 }
4919
4920 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
4923 let path = self.review_record_path(number);
4924 let text = std::fs::read_to_string(&path).ok()?;
4925 match serde_json::from_str(&text) {
4926 Ok(record) => Some(record),
4927 Err(e) => {
4928 logdim!("could not read {}: {e}", path.display());
4929 None
4930 }
4931 }
4932 }
4933
4934 pub fn write_review_record(
4935 &self,
4936 number: i64,
4937 record: &crate::model::ReviewRecord,
4938 ) -> Result<()> {
4939 write_json_atomic(&self.review_record_path(number), record)
4940 }
4941
4942 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4957 let path = self.followups_path();
4958 let heading = format!("## {}", title.trim());
4959 for seen in [&path, &self.worked_followups_path()] {
4960 if let Ok(existing) = std::fs::read_to_string(seen) {
4961 if has_heading(&existing, title) {
4962 logdim!("follow-up already noted: {title}");
4963 return Followup::Covered(format!("note: {}", title.trim()));
4964 }
4965 }
4966 }
4967 if let Some(parent) = path.parent() {
4968 let _ = std::fs::create_dir_all(parent);
4969 }
4970 use std::io::Write;
4971 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4978 match std::fs::OpenOptions::new()
4979 .create(true)
4980 .append(true)
4981 .open(&path)
4982 {
4983 Ok(mut file) => match file.write_all(entry.as_bytes()) {
4984 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4985 Err(e) => {
4986 logdim!("could not write {}: {e}", path.display());
4987 Followup::Failed
4988 }
4989 },
4990 Err(e) => {
4991 logdim!("could not write {}: {e}", path.display());
4992 Followup::Failed
4993 }
4994 }
4995 }
4996
4997 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5002 let path = self.worked_followups_path();
5003 if let Some(parent) = path.parent() {
5004 let _ = std::fs::create_dir_all(parent);
5005 }
5006 use std::io::Write;
5007 let entry = format!(
5008 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5009 title.trim(),
5010 body.trim()
5011 );
5012 if let Ok(mut file) = std::fs::OpenOptions::new()
5013 .create(true)
5014 .append(true)
5015 .open(&path)
5016 {
5017 let _ = file.write_all(entry.as_bytes());
5018 }
5019 }
5020
5021 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5030 self.root
5031 .join(STATE_DIR)
5032 .join("reviews")
5033 .join(format!("pr-{number}.md"))
5034 }
5035
5036 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5042 let path = self.pending_comment_path(number);
5043 if let Some(parent) = path.parent() {
5044 std::fs::create_dir_all(parent)
5045 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5046 }
5047 std::fs::write(&path, text)
5048 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5049 Ok(path)
5050 }
5051
5052 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5053 std::fs::read_to_string(self.pending_comment_path(number)).ok()
5054 }
5055
5056 pub fn state_path(&self, number: i64) -> PathBuf {
5057 self.root
5058 .join(STATE_DIR)
5059 .join("state")
5060 .join(format!("pr-{number}.json"))
5061 }
5062
5063 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5064 let path = self.state_path(number);
5065 let text = std::fs::read_to_string(&path).ok()?;
5066 match serde_json::from_str(&text) {
5067 Ok(state) => Some(state),
5068 Err(_) => {
5069 logdim!("could not read {}, starting fresh", path.display());
5070 None
5071 }
5072 }
5073 }
5074
5075 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5076 if let Some(local) = self.read_local_state(pr.number) {
5077 return Some(local);
5078 }
5079 if self.state_store.writes_pr() {
5080 return self.read_pr_state(pr.number);
5081 }
5082 None
5083 }
5084
5085 pub(crate) fn read_state_for_head(
5086 &self,
5087 pr: &PrView,
5088 actual_head: &str,
5089 ) -> Option<PersistedState> {
5090 let local = self
5091 .state_store
5092 .writes_local()
5093 .then(|| self.read_local_state(pr.number))
5094 .flatten();
5095 let remote = self
5096 .state_store
5097 .writes_pr()
5098 .then(|| self.read_pr_state(pr.number))
5099 .flatten();
5100 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5101 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5102 self.remember_checkpoint(pr.number, checkpoint);
5103 }
5104 choose_state_for_head(candidates, actual_head)
5105 }
5106
5107 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5108 self.try_read_pr_state(number).ok().flatten()
5109 }
5110
5111 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5112 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5113 if let Some(state) = parse_state_comment(&body) {
5114 return Ok(Some(state));
5115 }
5116 }
5117 Ok(None)
5118 }
5119
5120 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5121 let remote_state = if self.state_store.writes_pr() {
5122 self.try_read_pr_state(number)
5123 } else {
5124 Ok(None)
5125 };
5126 self.write_state_after_remote_read(number, state, remote_state)
5127 }
5128
5129 fn write_state_after_remote_read(
5130 &self,
5131 number: i64,
5132 state: &PersistedState,
5133 remote_state: Result<Option<PersistedState>>,
5134 ) -> Result<()> {
5135 let remote_checkpoint = if self.state_store.writes_pr() {
5136 self.record_failed_write(remote_state)?
5137 .map(|saved| saved.checkpoint)
5138 .unwrap_or_default()
5139 } else {
5140 0
5141 };
5142 let local_checkpoint = self
5143 .state_store
5144 .writes_local()
5145 .then(|| self.read_local_state(number))
5146 .flatten()
5147 .map(|saved| saved.checkpoint)
5148 .unwrap_or_default();
5149 let mut stamped = state.clone();
5150 stamped.checkpoint = state
5151 .checkpoint
5152 .max(local_checkpoint)
5153 .max(remote_checkpoint)
5154 .max(self.remembered_checkpoint(number))
5155 .saturating_add(1);
5156 self.remember_checkpoint(number, stamped.checkpoint);
5157 if self.state_store.writes_local() {
5158 write_json_atomic(&self.state_path(number), &stamped)?;
5159 }
5160 if self.state_store.writes_pr() {
5161 self.write_pr_state(number, &stamped)?;
5162 }
5163 Ok(())
5164 }
5165
5166 fn remembered_checkpoint(&self, number: i64) -> u64 {
5167 self.checkpoints
5168 .lock()
5169 .unwrap_or_else(std::sync::PoisonError::into_inner)
5170 .get(&number)
5171 .copied()
5172 .unwrap_or_default()
5173 }
5174
5175 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
5176 let mut checkpoints = self
5177 .checkpoints
5178 .lock()
5179 .unwrap_or_else(std::sync::PoisonError::into_inner);
5180 let saved = checkpoints.entry(number).or_default();
5181 *saved = (*saved).max(checkpoint);
5182 }
5183
5184 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5185 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
5189 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
5190 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
5191 if let Some(id) = comment_id {
5192 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5193 let field = format!("body={body}");
5194 let written = self
5195 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
5196 .map(|_| ());
5197 return self.record_write(written);
5198 }
5199 let written = self
5200 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
5201 .map(|_| ());
5202 self.record_write(written)
5203 }
5204
5205 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
5213 self.try_issue_comments(number).unwrap_or_default()
5214 }
5215
5216 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
5217 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
5218 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
5219 }
5220
5221 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
5228 let viewer = self.viewer_login()?.to_string();
5229 let mut mine = Vec::new();
5230 let mut theirs = Vec::new();
5231 for comment in self.try_issue_comments(number)? {
5232 let Some(body) = comment.get("body").and_then(Value::as_str) else {
5233 continue;
5234 };
5235 if !body.contains("spar:state") {
5236 continue;
5237 }
5238 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
5239 continue;
5240 };
5241 let author = comment
5242 .get("user")
5243 .and_then(|u| u.get("login"))
5244 .and_then(Value::as_str)
5245 .unwrap_or_default();
5246 if is_state_comment(body, author, &viewer) {
5247 mine.push((id, body.to_string()));
5248 } else {
5249 theirs.push(format!("@{author}"));
5250 }
5251 }
5252 if !theirs.is_empty() {
5253 logdim!(
5254 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
5255 own state block is patched or deleted.",
5256 theirs.len(),
5257 theirs.join(", ")
5258 );
5259 }
5260 Ok(mine)
5261 }
5262
5263 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
5264 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
5265 }
5266
5267 pub fn clear_state(&self, number: i64) {
5269 let path = self.state_path(number);
5270 let _ = std::fs::remove_file(&path);
5271 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
5272 }
5273
5274 pub fn prune_state(&self) -> Vec<String> {
5281 let base = self.root.join(STATE_DIR).join("state");
5282 let Ok(entries) = std::fs::read_dir(&base) else {
5283 return Vec::new();
5284 };
5285 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
5286 for name in entries
5287 .flatten()
5288 .filter_map(|e| e.file_name().to_str().map(str::to_string))
5289 {
5290 if let Some(number) = state_file_number(&name) {
5291 by_number.entry(number).or_default().push(name);
5292 }
5293 }
5294
5295 let mut removed = Vec::new();
5296 for (number, mut names) in by_number {
5297 if !is_finished(&self.pr_state(number)) {
5298 continue;
5299 }
5300 names.sort();
5301 for name in names {
5302 let _ = std::fs::remove_file(base.join(&name));
5303 removed.push(format!("state {name}"));
5304 }
5305 }
5306 removed
5307 }
5308
5309 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
5313 #[derive(Deserialize)]
5314 struct Row {
5315 number: i64,
5316 }
5317 let numbers = match numbers {
5318 Some(numbers) => numbers,
5319 None => {
5320 let listed: Result<Vec<i64>> = (|| {
5321 let text = self.gh(&[
5322 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
5323 ])?;
5324 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5325 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
5326 Ok(rows.into_iter().map(|row| row.number).collect())
5327 })();
5328 match self.record_failed_write(listed) {
5329 Ok(numbers) => numbers,
5330 Err(e) => {
5331 logdim!("could not inspect pull requests for state cleanup: {e}");
5332 return Vec::new();
5333 }
5334 }
5335 }
5336 };
5337
5338 let mut removed = Vec::new();
5339 for number in numbers {
5340 let state = match self.record_failed_write(self.try_pr_state(number)) {
5341 Ok(state) => state,
5342 Err(e) => {
5343 logdim!("could not inspect PR #{number} for state cleanup: {e}");
5344 continue;
5345 }
5346 };
5347 if !is_finished(&state) {
5348 continue;
5349 }
5350 let comments = match self.record_failed_write(self.try_state_comments(number)) {
5351 Ok(comments) => comments,
5352 Err(e) => {
5353 logdim!("could not inspect state comments on PR #{number}: {e}");
5354 continue;
5355 }
5356 };
5357 for (id, _) in comments {
5358 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5359 let deleted = self
5360 .gh(&["api", "-X", "DELETE", &path, "--silent"])
5361 .map(|_| ());
5362 match self.record_write(deleted) {
5363 Ok(()) => removed.push(format!("state comment on PR #{number}")),
5364 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
5365 }
5366 }
5367 }
5368 removed
5369 }
5370
5371 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
5378 let base = self.root.join(WORKTREE_DIR);
5379 let mut removed = Vec::new();
5380 let known = self.known_branches();
5381
5382 if let Ok(entries) = std::fs::read_dir(&base) {
5383 let mut names: Vec<String> = entries
5384 .flatten()
5385 .filter(|e| e.path().is_dir())
5386 .filter_map(|e| e.file_name().to_str().map(str::to_string))
5387 .collect();
5388 names.sort();
5389
5390 for name in names {
5391 if let Some(rest) = name.strip_prefix("review-") {
5394 let number: i64 = rest.parse().unwrap_or(-1);
5395 if !(force_all || is_finished(&self.pr_state(number))) {
5396 continue;
5397 }
5398 let path = base.join(&name);
5399 if force_all {
5400 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
5401 if !belongs {
5402 return Ok(false);
5403 }
5404 let local_ref = review_ref(number);
5405 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
5406 return Ok(false);
5407 }
5408 let head = self.head_oid_checked(&path)?;
5409 let recorded = self
5410 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
5411 .trim()
5412 .to_string();
5413 Ok(head == recorded)
5414 });
5415 match owned {
5416 Ok(true) => {}
5417 Ok(false) => {
5418 logdim!(
5419 "kept {} because no matching SPAR review reference proves \
5420 ownership",
5421 path.display()
5422 );
5423 continue;
5424 }
5425 Err(e) => {
5426 logdim!(
5427 "kept {} because review ownership could not be verified: {}",
5428 path.display(),
5429 e.last_line()
5430 );
5431 continue;
5432 }
5433 }
5434 } else {
5435 if let Err(e) = self.refuse_review_worktree_changes(number) {
5436 logdim!(
5437 "kept {} because its review state could not be verified as \
5438 disposable: {}",
5439 path.display(),
5440 e.last_line()
5441 );
5442 continue;
5443 }
5444 }
5445 if force_all {
5446 if self.remove_worktree_at_force(&path) {
5447 self.git_try(&["update-ref", "-d", &review_ref(number)]);
5448 }
5449 } else {
5450 self.release_review_worktree(number);
5451 }
5452 if !path.exists() {
5453 removed.push(name);
5454 }
5455 continue;
5456 }
5457 let branch = format!("{}{name}", self.branch_prefix);
5458 if !(force_all || self.worktree_is_done(&branch)) {
5459 continue;
5460 }
5461 if !known.contains_key(&branch) {
5462 logdim!("kept {branch} because it has no branch record");
5463 continue;
5464 }
5465 let path = base.join(&name);
5466 if !force_all {
5467 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
5468 Ok(true) => {
5469 logdim!(
5470 "kept {} because it contains uncommitted changes or ignored files",
5471 path.display()
5472 );
5473 continue;
5474 }
5475 Err(e) => {
5476 logdim!(
5477 "kept {} because its Git state could not be checked: {}",
5478 path.display(),
5479 e.last_line()
5480 );
5481 continue;
5482 }
5483 Ok(false) => {}
5484 }
5485 match self.branch_deletion_is_safe(&branch) {
5486 Ok(true) => {}
5487 Ok(false) => {
5488 logdim!(
5489 "kept {branch} because no surviving ref preserves its tip or \
5490 reflog-only commits"
5491 );
5492 continue;
5493 }
5494 Err(e) => {
5495 logdim!(
5496 "kept {branch} because preservation could not be verified: {}",
5497 e.last_line()
5498 );
5499 continue;
5500 }
5501 }
5502 }
5503 let removed_worktree = if force_all {
5504 self.remove_worktree_at_force(&path)
5505 } else {
5506 match self.remove_worktree_at(&path) {
5507 Ok(removed) => removed,
5508 Err(error) => {
5509 logdim!(
5510 "kept {branch} and {} because removal did not reach a confirmed \
5511 quiet point: {}",
5512 path.display(),
5513 error.last_line()
5514 );
5515 false
5516 }
5517 }
5518 };
5519 if !removed_worktree {
5520 continue;
5521 }
5522 if force_all {
5523 self.git_try(&["branch", "-D", &branch]);
5524 self.forget_branch(&branch);
5525 } else {
5526 match self.delete_branch_if_safe(&branch) {
5527 Ok(true) => self.forget_branch(&branch),
5528 Ok(false) => logdim!(
5529 "kept {branch} because its tip or reflog changed before deletion"
5530 ),
5531 Err(error) => logdim!(
5532 "kept {branch} because deletion safety could not be rechecked: {}",
5533 error.last_line()
5534 ),
5535 }
5536 }
5537 removed.push(name);
5538 }
5539 }
5540 removed.extend(self.prune_branches(force_all));
5541 removed
5542 }
5543
5544 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
5551 let known = self.known_branches();
5552 let branches: Vec<String> = known.keys().cloned().collect();
5553 if branches.is_empty() {
5554 return Vec::new();
5555 }
5556
5557 let checked_out: Vec<String> = self
5558 .git_try(&["worktree", "list", "--porcelain"])
5559 .lines()
5560 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
5561 .collect();
5562
5563 let existing: Vec<String> = self
5566 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
5567 .lines()
5568 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
5569 .collect();
5570
5571 let mut removed = Vec::new();
5572 for branch in branches {
5573 if !existing.contains(&branch) {
5574 self.forget_branch(&branch); continue;
5576 }
5577 if checked_out.contains(&branch) {
5578 continue;
5579 }
5580 if !(force_all || self.worktree_is_done(&branch)) {
5581 continue;
5582 }
5583 if !force_all {
5584 let Some(_record) = known.get(&branch) else {
5585 continue;
5586 };
5587 match self.branch_deletion_is_safe(&branch) {
5588 Ok(true) => {}
5589 Ok(false) => {
5590 logdim!(
5591 "kept {branch} because no surviving ref preserves its tip or \
5592 reflog-only commits"
5593 );
5594 continue;
5595 }
5596 Err(e) => {
5597 logdim!(
5598 "kept {branch} because preservation could not be verified: {}",
5599 e.last_line()
5600 );
5601 continue;
5602 }
5603 }
5604 }
5605 let deleted = if force_all {
5606 self.git(&["branch", "-D", &branch]).map(|_| true)
5607 } else {
5608 self.delete_branch_if_safe(&branch)
5609 };
5610 match deleted {
5611 Ok(true) => {
5612 self.forget_branch(&branch);
5613 removed.push(format!("branch {branch}"));
5614 }
5615 Ok(false) => {
5616 logdim!("kept {branch} because its tip or reflog changed before deletion");
5617 }
5618 Err(e) => {
5619 logdim!("could not delete {branch}: {}", e.last_line());
5622 }
5623 }
5624 }
5625 removed
5626 }
5627
5628 fn worktree_is_done(&self, branch: &str) -> bool {
5630 #[derive(Deserialize)]
5631 struct Row {
5632 state: String,
5633 }
5634 let entry = branch
5635 .strip_prefix(self.branch_prefix.as_str())
5636 .unwrap_or(branch);
5637 if let Some(rest) = entry.strip_prefix("pr-") {
5638 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
5639 }
5640 if entry.starts_with("issue-") || entry.starts_with("split-") {
5644 let text = self.gh_try(&[
5645 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
5646 ]);
5647 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5648 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
5649 }
5650 false
5651 }
5652}
5653
5654pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5666 match probe() {
5667 Ok(value) => Ok(value),
5668 Err(first) => {
5669 logdim!("retrying a worktree probe after: {}", first.last_line());
5670 probe()
5671 }
5672 }
5673}
5674
5675pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5681 let root = std::fs::canonicalize(cwd)
5682 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5683 let mut files = BTreeMap::new();
5684 let mut visited = BTreeSet::new();
5685 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5686 Ok(AttributeState { files })
5687}
5688
5689fn collect_attribute_files(
5690 root: &Path,
5691 repository: &Path,
5692 prefix: &Path,
5693 visited: &mut BTreeSet<PathBuf>,
5694 files: &mut BTreeMap<PathBuf, [u8; 32]>,
5695) -> Result<()> {
5696 let canonical = std::fs::canonicalize(repository)
5697 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5698 if !visited.insert(canonical) {
5699 bail!("submodule recursion revisited {}", repository.display());
5700 }
5701 let entries = index_entries(repository)?;
5702 let mut paths: BTreeSet<PathBuf> = entries
5703 .iter()
5704 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5705 .map(|entry| entry.path.clone())
5706 .collect();
5707 paths.extend(untracked_attribute_paths(repository)?);
5713 for path in paths {
5714 let from_root = prefix.join(&path);
5715 let state = attribute_file_fingerprint(&root.join(&from_root))?;
5716 files.insert(from_root, state);
5717 }
5718 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5719 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5720 continue;
5721 };
5722 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5723 }
5724 Ok(())
5725}
5726
5727pub(crate) fn uncertain_worktree_change(
5730 cwd: &Path,
5731 message: impl Into<String>,
5732) -> crate::error::SparError {
5733 let message = message.into();
5734 let marker = write_recovery_marker(cwd, &message);
5735 let note = match marker {
5736 Ok(path) => format!(" Recovery marker: {}.", path.display()),
5737 Err(e) => format!(
5738 " A recovery marker could not be written: {}.",
5739 e.last_line()
5740 ),
5741 };
5742 crate::error::SparError::uncertain_write(format!("{message}{note}"))
5743}
5744
5745fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5746 use std::sync::atomic::{AtomicU32, Ordering};
5747 static NEXT: AtomicU32 = AtomicU32::new(0);
5748 for _ in 0..1000 {
5749 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5750 let path = cwd.join(format!(
5751 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5752 std::process::id()
5753 ));
5754 let mut options = OpenOptions::new();
5755 options.write(true).create_new(true);
5756 #[cfg(unix)]
5757 {
5758 use std::os::unix::fs::OpenOptionsExt;
5759 options.mode(0o600);
5760 }
5761 match options.open(&path) {
5762 Ok(mut file) => {
5763 file.write_all(detail.as_bytes())
5764 .and_then(|_| file.write_all(b"\n"))
5765 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5766 return Ok(path);
5767 }
5768 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5769 Err(e) => {
5770 return Err(spar_err!(
5771 "could not create a recovery marker in {}: {e}",
5772 cwd.display()
5773 ))
5774 }
5775 }
5776 }
5777 bail!(
5778 "could not choose a free recovery marker name in {}",
5779 cwd.display()
5780 )
5781}
5782
5783fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5788 let mut argv = vec![
5789 "git".to_string(),
5790 "-c".to_string(),
5791 "maintenance.auto=false".to_string(),
5792 "-c".to_string(),
5793 "gc.auto=0".to_string(),
5794 ];
5795 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5796 argv
5797}
5798
5799fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5800 let mut argv = git_without_maintenance_argv(&[]);
5801 argv.extend([
5802 "-c".to_string(),
5803 "core.fsmonitor=".to_string(),
5804 "-c".to_string(),
5805 "commit.gpgsign=false".to_string(),
5806 "-c".to_string(),
5807 "core.hooksPath=/dev/null".to_string(),
5808 ]);
5809 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5810 argv
5811}
5812
5813pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5820 let root = std::fs::canonicalize(cwd)
5821 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5822 let mut files = BTreeMap::new();
5823 let mut ignored = BTreeSet::new();
5824 let mut visited = BTreeSet::new();
5825 collect_untracked_files(
5826 &root,
5827 &root,
5828 Path::new(""),
5829 &mut visited,
5830 &mut files,
5831 &mut ignored,
5832 )?;
5833 Ok(IgnoredState { files, ignored })
5834}
5835
5836fn collect_untracked_files(
5837 root: &Path,
5838 repository: &Path,
5839 prefix: &Path,
5840 visited: &mut BTreeSet<PathBuf>,
5841 files: &mut BTreeMap<PathBuf, UntrackedFile>,
5842 ignored: &mut BTreeSet<PathBuf>,
5843) -> Result<()> {
5844 let canonical = std::fs::canonicalize(repository)
5845 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5846 if !visited.insert(canonical.clone()) {
5847 bail!("submodule recursion revisited {}", canonical.display());
5848 }
5849 let listed = run_git_bytes(
5853 repository,
5854 &["ls-files", "--others", "--exclude-standard", "-z"],
5855 )?;
5856 if !listed.is_empty() && !listed.ends_with(&[0]) {
5857 bail!(
5858 "git returned an unterminated untracked-file list for {}",
5859 repository.display()
5860 );
5861 }
5862
5863 for raw in listed
5864 .split(|byte| *byte == 0)
5865 .filter(|raw| !raw.is_empty())
5866 {
5867 let (relative, nested) = untracked_record(raw, "untracked")?;
5868 let from_root = prefix.join(&relative);
5869 if is_spar_own_path(&from_root) {
5870 continue;
5871 }
5872 let absolute = root.join(&from_root);
5873 let fingerprint = if nested {
5874 nested_repository_fingerprint(&absolute)?
5875 } else {
5876 ignored_file_fingerprint(&absolute)?
5877 };
5878 if files.insert(from_root.clone(), fingerprint).is_some() {
5879 bail!(
5880 "git returned the untracked path more than once: {:?}",
5881 from_root
5882 );
5883 }
5884 }
5885
5886 let ignored_listed = run_git_bytes(
5887 repository,
5888 &[
5889 "ls-files",
5890 "--others",
5891 "--ignored",
5892 "--exclude-standard",
5893 "-z",
5894 ],
5895 )?;
5896 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5897 bail!(
5898 "git returned an unterminated ignored-file list for {}",
5899 repository.display()
5900 );
5901 }
5902 for raw in ignored_listed
5908 .split(|byte| *byte == 0)
5909 .filter(|raw| !raw.is_empty())
5910 {
5911 let (relative, nested) = untracked_record(raw, "ignored")?;
5912 let from_root = prefix.join(relative);
5913 if is_spar_own_path(&from_root) {
5914 continue;
5915 }
5916 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5917 let absolute = root.join(&from_root);
5918 let fingerprint = if nested {
5919 nested_repository_fingerprint(&absolute)?
5920 } else {
5921 ignored_file_fingerprint(&absolute)?
5922 };
5923 slot.insert(fingerprint);
5924 }
5925 if !ignored.insert(from_root.clone()) {
5926 bail!(
5927 "git returned the ignored path more than once: {:?}",
5928 from_root
5929 );
5930 }
5931 }
5932
5933 for link in gitlinks(repository)? {
5934 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5935 continue;
5936 };
5937 collect_untracked_files(
5938 root,
5939 &submodule,
5940 &prefix.join(&link.path),
5941 visited,
5942 files,
5943 ignored,
5944 )?;
5945 }
5946 Ok(())
5947}
5948
5949fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5950 let argv = git_without_automation_argv(args);
5951 proc::run_bytes(
5952 &argv,
5953 &ExecOpts::new()
5954 .cwd(cwd)
5955 .timeout_secs(30)
5956 .stop_descendants(true),
5957 )
5958}
5959
5960fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5961 let argv = git_without_automation_argv(args);
5962 proc::run(
5963 &argv,
5964 &ExecOpts::new()
5965 .cwd(cwd)
5966 .timeout_secs(30)
5967 .stop_descendants(true),
5968 )
5969}
5970
5971fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5972 let path = path.to_str().ok_or_else(|| {
5973 spar_err!(
5974 "cannot verify filtered content for a non-UTF-8 path in {}",
5975 cwd.display()
5976 )
5977 })?;
5978 let path_arg = format!("--path={path}");
5979 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5980 Ok(Sha256::digest(bytes).into())
5981}
5982
5983fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5984 let relative = path_from_git_bytes(raw)?;
5985 if relative.is_absolute()
5986 || relative.components().any(|component| {
5987 matches!(
5988 component,
5989 std::path::Component::ParentDir
5990 | std::path::Component::RootDir
5991 | std::path::Component::Prefix(_)
5992 )
5993 })
5994 {
5995 bail!("git returned an unsafe {kind} path: {:?}", relative);
5996 }
5997 Ok(relative)
5998}
5999
6000fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6009 let nested = raw.last() == Some(&b'/');
6010 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6011 if trimmed.is_empty() {
6012 bail!("git returned an empty {kind} path");
6013 }
6014 Ok((safe_git_path(trimmed, kind)?, nested))
6015}
6016
6017fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6018 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6019 if !listed.is_empty() && !listed.ends_with(&[0]) {
6020 bail!(
6021 "git returned an unterminated index listing for {}",
6022 cwd.display()
6023 );
6024 }
6025 let mut entries = Vec::new();
6026 for record in listed
6027 .split(|byte| *byte == 0)
6028 .filter(|record| !record.is_empty())
6029 {
6030 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6031 bail!(
6032 "git returned a malformed index record for {}",
6033 cwd.display()
6034 );
6035 };
6036 let header = &record[..tab];
6037 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6038 if fields.len() != 3 {
6039 bail!(
6040 "git returned a malformed index header for {}",
6041 cwd.display()
6042 );
6043 }
6044 if fields[2] != b"0" {
6045 continue;
6046 }
6047 let mode = std::str::from_utf8(fields[0])
6048 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6049 .to_string();
6050 let oid = std::str::from_utf8(fields[1])
6051 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6052 .to_string();
6053 entries.push(IndexEntry {
6054 path: safe_git_path(&record[tab + 1..], "index")?,
6055 mode,
6056 oid,
6057 });
6058 }
6059 Ok(entries)
6060}
6061
6062fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6064 let listed = run_git_bytes(
6065 cwd,
6066 &[
6067 "ls-files",
6068 "--others",
6069 "--exclude-standard",
6070 "-z",
6071 "--",
6072 ".gitattributes",
6073 ":(glob)**/.gitattributes",
6074 ],
6075 )?;
6076 if !listed.is_empty() && !listed.ends_with(&[0]) {
6077 bail!(
6078 "git returned an unterminated attribute-file listing for {}",
6079 cwd.display()
6080 );
6081 }
6082 listed
6083 .split(|byte| *byte == 0)
6084 .filter(|record| !record.is_empty())
6085 .map(|raw| safe_git_path(raw, "attribute"))
6086 .collect()
6087}
6088
6089fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6090 if !untracked_attribute_paths(cwd)?.is_empty() {
6091 return Ok(true);
6092 }
6093
6094 let index = index_entries(cwd)?
6095 .into_iter()
6096 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6097 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6098 .collect::<BTreeMap<_, _>>();
6099 let head = tree_entries(cwd, "HEAD")?
6100 .into_iter()
6101 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6102 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6103 .collect::<BTreeMap<_, _>>();
6104 if index != head {
6105 return Ok(true);
6106 }
6107
6108 let effective = check_attributes(cwd, index.keys().cloned())?;
6109 let config = CheckoutConfig::read(cwd)?;
6110 for (path, (_mode, oid)) in index {
6111 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6112 return Ok(true);
6113 };
6114 let attributes = effective
6115 .get(&path)
6116 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6117 if allows_expected_crlf(&config, attributes)? {
6118 if worktree.mode == "120000" {
6119 return Ok(true);
6120 }
6121 let (normalized, every_lf_was_crlf) =
6122 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
6123 if !every_lf_was_crlf || normalized != oid {
6124 return Ok(true);
6125 }
6126 } else if worktree.raw_oid != oid {
6127 return Ok(true);
6128 }
6129 }
6130 Ok(false)
6131}
6132
6133fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
6134 Ok(index_entries(cwd)?
6135 .into_iter()
6136 .filter(|entry| entry.mode == "160000")
6137 .map(|entry| Gitlink {
6138 path: entry.path,
6139 oid: entry.oid,
6140 })
6141 .collect())
6142}
6143
6144fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
6145 let mut tracked = BTreeMap::new();
6146 for entry in index_entries(cwd)? {
6147 if entry.mode == "160000" {
6148 continue;
6149 }
6150 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
6151 tracked.insert(
6152 entry.path,
6153 TrackedEntry {
6154 index_mode: entry.mode,
6155 index_oid: entry.oid,
6156 worktree,
6157 },
6158 );
6159 }
6160 Ok(tracked)
6161}
6162
6163fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
6164 let metadata = match std::fs::symlink_metadata(path) {
6165 Ok(metadata) => metadata,
6166 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6167 Err(e) => {
6168 return Err(spar_err!(
6169 "could not inspect tracked file {}: {e}",
6170 path.display()
6171 ))
6172 }
6173 };
6174 let mut fingerprint = Sha256::new();
6175 if metadata.file_type().is_symlink() {
6176 let target = std::fs::read_link(path)
6177 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
6178 let bytes = os_str_bytes(target.as_os_str())?;
6179 fingerprint.update(b"symlink\0");
6180 fingerprint.update(&bytes);
6181 let content = Sha256::digest(&bytes).into();
6182 return Ok(Some(WorktreeFile {
6183 mode: "120000".to_string(),
6184 #[cfg(unix)]
6185 permissions: 0,
6186 raw_oid: git_blob_oid(oid_len, &bytes)?,
6187 fingerprint: fingerprint.finalize().into(),
6188 content,
6189 }));
6190 }
6191 if !metadata.is_file() {
6192 bail!("tracked path {} is not a file or symlink", path.display());
6193 }
6194
6195 let mut options = OpenOptions::new();
6196 options.read(true);
6197 #[cfg(unix)]
6198 {
6199 use std::os::unix::fs::OpenOptionsExt;
6200 options.custom_flags(libc::O_NOFOLLOW);
6201 }
6202 let mut file = options
6203 .open(path)
6204 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6205 let before = file
6206 .metadata()
6207 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6208 let mode = tracked_file_mode(&before);
6209 #[cfg(unix)]
6210 let permissions = {
6211 use std::os::unix::fs::MetadataExt;
6212 before.mode() & 0o7777
6213 };
6214 fingerprint.update(b"file\0");
6215 fingerprint.update(mode.as_bytes());
6216 #[cfg(unix)]
6217 fingerprint.update(permissions.to_le_bytes());
6218 fingerprint.update(before.len().to_le_bytes());
6219 let mut content = Sha256::new();
6220 let header = format!("blob {}\0", before.len());
6221 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6222 let mut buf = [0u8; 64 * 1024];
6223 loop {
6224 let read = file
6225 .read(&mut buf)
6226 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6227 if read == 0 {
6228 break;
6229 }
6230 fingerprint.update(&buf[..read]);
6231 content.update(&buf[..read]);
6232 object.update(&buf[..read]);
6233 }
6234 let after = file
6235 .metadata()
6236 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6237 if before.len() != after.len()
6238 || before.modified().ok() != after.modified().ok()
6239 || before.permissions() != after.permissions()
6240 {
6241 bail!(
6242 "tracked file {} changed while it was being inspected",
6243 path.display()
6244 );
6245 }
6246 let current = std::fs::symlink_metadata(path)
6247 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6248 if !same_file(&after, ¤t) {
6249 bail!(
6250 "tracked file {} was replaced while it was being inspected",
6251 path.display()
6252 );
6253 }
6254 Ok(Some(WorktreeFile {
6255 mode,
6256 #[cfg(unix)]
6257 permissions,
6258 raw_oid: object.finish(),
6259 fingerprint: fingerprint.finalize().into(),
6260 content: content.finalize().into(),
6261 }))
6262}
6263
6264fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
6265 let metadata = std::fs::symlink_metadata(path)
6266 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6267 let mut digest = Sha256::new();
6268 if metadata.file_type().is_symlink() {
6269 digest.update(b"symlink\0");
6270 let target = std::fs::read_link(path)
6271 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
6272 digest.update(os_str_bytes(target.as_os_str())?);
6273 return Ok(digest.finalize().into());
6274 }
6275 if !metadata.is_file() {
6276 bail!("attribute path {} is not a file or symlink", path.display());
6277 }
6278 let mut options = OpenOptions::new();
6279 options.read(true);
6280 #[cfg(unix)]
6281 {
6282 use std::os::unix::fs::OpenOptionsExt;
6283 options.custom_flags(libc::O_NOFOLLOW);
6284 }
6285 let mut file = options
6286 .open(path)
6287 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6288 let before = file
6289 .metadata()
6290 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6291 digest.update(b"file\0");
6292 let mut buf = [0u8; 64 * 1024];
6293 loop {
6294 let read = file
6295 .read(&mut buf)
6296 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6297 if read == 0 {
6298 break;
6299 }
6300 digest.update(&buf[..read]);
6301 }
6302 let after = file
6303 .metadata()
6304 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6305 let current = std::fs::symlink_metadata(path)
6306 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6307 if before.len() != after.len()
6308 || before.modified().ok() != after.modified().ok()
6309 || !same_file(&after, ¤t)
6310 {
6311 bail!(
6312 "attribute file {} changed while it was being inspected",
6313 path.display()
6314 );
6315 }
6316 Ok(digest.finalize().into())
6317}
6318
6319enum ObjectHasher {
6320 Sha1(Sha1),
6321 Sha256(Sha256),
6322}
6323
6324impl ObjectHasher {
6325 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
6326 let mut hasher = match oid_len {
6327 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
6328 64 => Self::Sha256(Sha256::new()),
6329 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
6330 };
6331 hasher.update(header);
6332 Ok(hasher)
6333 }
6334
6335 fn update(&mut self, bytes: &[u8]) {
6336 match self {
6337 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
6338 Self::Sha256(hasher) => hasher.update(bytes),
6339 }
6340 }
6341
6342 fn finish(self) -> String {
6343 let bytes = match self {
6344 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
6345 Self::Sha256(hasher) => hasher.finalize().to_vec(),
6346 };
6347 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
6348 }
6349}
6350
6351fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
6352 let header = format!("blob {}\0", bytes.len());
6353 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
6354 hasher.update(bytes);
6355 Ok(hasher.finish())
6356}
6357
6358fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
6359 let mut first = open_regular_file(path)?;
6360 let first_before = first
6361 .metadata()
6362 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6363 let mut raw_len = 0u64;
6364 let mut crlf_pairs = 0u64;
6365 let mut previous_was_cr = false;
6366 let mut every_lf_was_crlf = true;
6367 let mut buf = [0u8; 64 * 1024];
6368 loop {
6369 let read = first
6370 .read(&mut buf)
6371 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6372 if read == 0 {
6373 break;
6374 }
6375 raw_len = raw_len
6376 .checked_add(read as u64)
6377 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
6378 for byte in &buf[..read] {
6379 if *byte == b'\n' {
6380 if previous_was_cr {
6381 crlf_pairs += 1;
6382 } else {
6383 every_lf_was_crlf = false;
6384 }
6385 }
6386 previous_was_cr = *byte == b'\r';
6387 }
6388 }
6389 let first_after = first
6390 .metadata()
6391 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6392 let current = std::fs::symlink_metadata(path)
6393 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6394 if raw_len != first_before.len()
6395 || !stable_file_metadata(&first_before, &first_after)
6396 || !stable_file_metadata(&first_after, ¤t)
6397 {
6398 bail!(
6399 "tracked file {} changed while line endings were inspected",
6400 path.display()
6401 );
6402 }
6403
6404 let normalized_len = raw_len
6405 .checked_sub(crlf_pairs)
6406 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
6407 let header = format!("blob {normalized_len}\0");
6408 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6409 let mut second = open_regular_file(path)?;
6410 let second_before = second
6411 .metadata()
6412 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6413 if !stable_file_metadata(&first_after, &second_before) {
6414 bail!(
6415 "tracked file {} changed between line-ending checks",
6416 path.display()
6417 );
6418 }
6419 let mut pending_cr = false;
6420 loop {
6421 let read = second
6422 .read(&mut buf)
6423 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6424 if read == 0 {
6425 break;
6426 }
6427 for byte in &buf[..read] {
6428 if pending_cr {
6429 if *byte == b'\n' {
6430 object.update(b"\n");
6431 pending_cr = false;
6432 continue;
6433 }
6434 object.update(b"\r");
6435 pending_cr = false;
6436 }
6437 if *byte == b'\r' {
6438 pending_cr = true;
6439 } else {
6440 object.update(std::slice::from_ref(byte));
6441 }
6442 }
6443 }
6444 if pending_cr {
6445 object.update(b"\r");
6446 }
6447 let second_after = second
6448 .metadata()
6449 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6450 let current = std::fs::symlink_metadata(path)
6451 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6452 if !stable_file_metadata(&second_before, &second_after)
6453 || !stable_file_metadata(&second_after, ¤t)
6454 {
6455 bail!(
6456 "tracked file {} changed while line endings were hashed",
6457 path.display()
6458 );
6459 }
6460 Ok((object.finish(), every_lf_was_crlf))
6461}
6462
6463fn open_regular_file(path: &Path) -> Result<std::fs::File> {
6464 let mut options = OpenOptions::new();
6465 options.read(true);
6466 #[cfg(unix)]
6467 {
6468 use std::os::unix::fs::OpenOptionsExt;
6469 options.custom_flags(libc::O_NOFOLLOW);
6470 }
6471 let file = options
6472 .open(path)
6473 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6474 let metadata = file
6475 .metadata()
6476 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6477 if !metadata.is_file() {
6478 bail!("tracked path {} is not a regular file", path.display());
6479 }
6480 Ok(file)
6481}
6482
6483fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6484 if !same_file(left, right)
6485 || left.len() != right.len()
6486 || left.modified().ok() != right.modified().ok()
6487 || left.permissions() != right.permissions()
6488 {
6489 return false;
6490 }
6491 #[cfg(unix)]
6492 {
6493 use std::os::unix::fs::MetadataExt;
6494 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
6495 }
6496 #[cfg(not(unix))]
6497 {
6498 left.created().ok() == right.created().ok()
6499 }
6500}
6501
6502const ATTRIBUTE_BATCH: usize = 1000;
6509
6510fn check_attributes(
6511 cwd: &Path,
6512 paths: impl IntoIterator<Item = PathBuf>,
6513) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6514 let paths = paths.into_iter().collect::<BTreeSet<_>>();
6515 if paths.is_empty() {
6516 return Ok(BTreeMap::new());
6517 }
6518 let ordered = paths.into_iter().collect::<Vec<_>>();
6519 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6520 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
6521 values.extend(check_attribute_batch(cwd, batch)?);
6522 }
6523 Ok(values)
6524}
6525
6526fn check_attribute_batch(
6527 cwd: &Path,
6528 batch: &[PathBuf],
6529) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6530 const NAMES: [&str; 6] = [
6531 "filter",
6532 "working-tree-encoding",
6533 "ident",
6534 "text",
6535 "eol",
6536 "crlf",
6537 ];
6538 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
6539 let mut input = String::new();
6540 for path in &paths {
6541 let path = path.to_str().ok_or_else(|| {
6542 spar_err!(
6543 "cannot inspect attributes for a non-UTF-8 path in {}",
6544 cwd.display()
6545 )
6546 })?;
6547 input.push_str(path);
6548 input.push('\0');
6549 }
6550 let argv = git_without_automation_argv(&[
6551 "check-attr",
6552 "-z",
6553 "--cached",
6554 "--stdin",
6555 "filter",
6556 "working-tree-encoding",
6557 "ident",
6558 "text",
6559 "eol",
6560 "crlf",
6561 ]);
6562 let output = proc::run_bytes(
6563 &argv,
6564 &ExecOpts::new()
6565 .cwd(cwd)
6566 .timeout_secs(30)
6567 .stdin(input)
6568 .stop_descendants(true),
6569 )?;
6570 if !output.is_empty() && !output.ends_with(&[0]) {
6571 bail!(
6572 "git returned an unterminated attribute result for {}",
6573 cwd.display()
6574 );
6575 }
6576 let fields = output
6577 .split(|byte| *byte == 0)
6578 .filter(|field| !field.is_empty())
6579 .collect::<Vec<_>>();
6580 if fields.len() != paths.len() * NAMES.len() * 3 {
6581 bail!(
6582 "git returned an unexpected attribute result for {}",
6583 cwd.display()
6584 );
6585 }
6586 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6587 for record in fields.chunks_exact(3) {
6588 let path = safe_git_path(record[0], "attribute")?;
6589 if !paths.contains(&path) {
6590 bail!(
6591 "git returned attributes for the wrong path in {}",
6592 cwd.display()
6593 );
6594 }
6595 let name = std::str::from_utf8(record[1])
6596 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
6597 let value = std::str::from_utf8(record[2])
6598 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
6599 values
6600 .entry(path)
6601 .or_default()
6602 .insert(name.to_string(), value.to_string());
6603 }
6604 if paths.iter().any(|path| {
6605 values
6606 .get(path)
6607 .is_none_or(|attributes| attributes.len() != NAMES.len())
6608 }) {
6609 bail!(
6610 "git omitted an attribute result for a tracked path in {}",
6611 cwd.display()
6612 );
6613 }
6614 Ok(values)
6615}
6616
6617fn attribute_is_active(value: Option<&String>) -> bool {
6618 !matches!(
6619 value.map(String::as_str),
6620 None | Some("unspecified") | Some("unset")
6621 )
6622}
6623
6624fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
6625 attribute_is_active(values.get("filter"))
6626 || attribute_is_active(values.get("working-tree-encoding"))
6627}
6628
6629struct CheckoutConfig {
6638 autocrlf: Option<String>,
6639 eol: Option<String>,
6640 symlinks: Option<bool>,
6641}
6642
6643impl CheckoutConfig {
6644 fn read(cwd: &Path) -> Result<Self> {
6645 Ok(Self {
6646 autocrlf: git_config_value(cwd, "core.autocrlf")?,
6647 eol: git_config_value(cwd, "core.eol")?,
6648 symlinks: git_config_bool(cwd, "core.symlinks")?,
6649 })
6650 }
6651
6652 fn autocrlf_is_true(&self) -> bool {
6653 self.autocrlf.as_ref().is_some_and(|value| {
6654 matches!(
6655 value.to_ascii_lowercase().as_str(),
6656 "true" | "yes" | "on" | "1"
6657 )
6658 })
6659 }
6660
6661 fn eol_is(&self, wanted: &str) -> bool {
6662 self.eol
6663 .as_ref()
6664 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6665 }
6666}
6667
6668fn path_has_ambiguous_transform(
6669 config: &CheckoutConfig,
6670 values: &BTreeMap<String, String>,
6671) -> Result<bool> {
6672 if path_has_external_transform(values)
6673 || attribute_is_active(values.get("ident"))
6674 || attribute_is_active(values.get("crlf"))
6675 {
6676 return Ok(true);
6677 }
6678 let text = values.get("text").map(String::as_str);
6679 let eol = values.get("eol").map(String::as_str);
6680 if text == Some("auto") {
6681 return Ok(true);
6682 }
6683 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6684 || !matches!(
6685 eol,
6686 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6687 )
6688 {
6689 return Ok(true);
6690 }
6691 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6692 return Ok(config.autocrlf_is_true());
6693 }
6694 Ok(false)
6695}
6696
6697fn allows_expected_crlf(
6698 config: &CheckoutConfig,
6699 values: &BTreeMap<String, String>,
6700) -> Result<bool> {
6701 if path_has_external_transform(values)
6702 || attribute_is_active(values.get("ident"))
6703 || attribute_is_active(values.get("crlf"))
6704 {
6705 return Ok(false);
6706 }
6707 let text = values.get("text").map(String::as_str);
6708 let eol = values.get("eol").map(String::as_str);
6709 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6710 return Ok(false);
6711 }
6712 if eol == Some("crlf") {
6713 return Ok(true);
6714 }
6715 if text != Some("set") {
6716 return Ok(false);
6717 }
6718 if let Some(autocrlf) = config.autocrlf.as_deref() {
6719 match autocrlf.to_ascii_lowercase().as_str() {
6720 "true" | "yes" | "on" | "1" => return Ok(true),
6721 "input" => return Ok(false),
6722 _ => {}
6723 }
6724 }
6725 if config.eol_is("crlf") {
6726 return Ok(true);
6727 }
6728 #[cfg(windows)]
6729 if config.eol.is_none() || config.eol_is("native") {
6730 return Ok(true);
6731 }
6732 Ok(false)
6733}
6734
6735fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6736 let argv = git_without_automation_argv(&["config", "--get", key]);
6737 let output = proc::exec(
6738 &argv,
6739 &ExecOpts::new()
6740 .cwd(cwd)
6741 .timeout_secs(30)
6742 .check(false)
6743 .stop_descendants(true),
6744 )?;
6745 match output.code {
6746 0 => Ok(Some(output.stdout.trim().to_string())),
6747 1 => Ok(None),
6748 _ => bail!(
6749 "could not read Git configuration in {}: {}",
6750 cwd.display(),
6751 output.stderr.trim()
6752 ),
6753 }
6754}
6755
6756fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6757 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6758 let output = proc::exec(
6759 &argv,
6760 &ExecOpts::new()
6761 .cwd(cwd)
6762 .timeout_secs(30)
6763 .check(false)
6764 .stop_descendants(true),
6765 )?;
6766 match output.code {
6767 0 if output.stdout.trim() == "true" => Ok(Some(true)),
6768 0 if output.stdout.trim() == "false" => Ok(Some(false)),
6769 0 => bail!(
6770 "git returned an invalid boolean for {key} in {}",
6771 cwd.display()
6772 ),
6773 1 => Ok(None),
6774 _ => bail!(
6775 "could not read Git configuration in {}: {}",
6776 cwd.display(),
6777 output.stderr.trim()
6778 ),
6779 }
6780}
6781
6782#[cfg(unix)]
6783fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6784 use std::os::unix::fs::PermissionsExt;
6785 if metadata.permissions().mode() & 0o111 == 0 {
6786 "100644".to_string()
6787 } else {
6788 "100755".to_string()
6789 }
6790}
6791
6792#[cfg(not(unix))]
6793fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6794 "100644".to_string()
6795}
6796
6797fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6798 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6799 if !listed.is_empty() && !listed.ends_with(&[0]) {
6800 bail!(
6801 "git returned an unterminated tree listing for {}",
6802 cwd.display()
6803 );
6804 }
6805 let mut entries = Vec::new();
6806 for record in listed
6807 .split(|byte| *byte == 0)
6808 .filter(|record| !record.is_empty())
6809 {
6810 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6811 bail!("git returned a malformed tree record for {}", cwd.display());
6812 };
6813 let fields = record[..tab]
6814 .split(|byte| *byte == b' ')
6815 .collect::<Vec<_>>();
6816 if fields.len() != 3 {
6817 bail!("git returned a malformed tree header for {}", cwd.display());
6818 }
6819 let mode = std::str::from_utf8(fields[0])
6820 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6821 .to_string();
6822 let oid = std::str::from_utf8(fields[2])
6823 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6824 .to_string();
6825 entries.push(IndexEntry {
6826 path: safe_git_path(&record[tab + 1..], "tree")?,
6827 mode,
6828 oid,
6829 });
6830 }
6831 Ok(entries)
6832}
6833
6834fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6835 Ok(tree_entries(cwd, "HEAD")?
6836 .into_iter()
6837 .filter(|entry| entry.mode == "160000")
6838 .map(|entry| (entry.path, entry.oid))
6839 .collect())
6840}
6841
6842fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6843 let head = head_gitlinks(cwd)?;
6844 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6845 .into_iter()
6846 .map(|link| (link.path, link.oid))
6847 .collect();
6848 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6849 paths.extend(index.keys().cloned());
6850 Ok(paths
6851 .into_iter()
6852 .filter(|path| head.get(path) != index.get(path))
6853 .collect())
6854}
6855
6856fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6857 let path = parent.join(relative);
6858 let metadata = match std::fs::symlink_metadata(&path) {
6859 Ok(metadata) => metadata,
6860 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6861 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6862 };
6863 if !metadata.is_dir() {
6864 bail!("the gitlink at {} is not a directory", path.display());
6865 }
6866 let canonical = std::fs::canonicalize(&path)
6867 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6868 if canonical != path {
6869 bail!(
6870 "the gitlink at {} resolves through a symlink",
6871 path.display()
6872 );
6873 }
6874 if !path.join(".git").exists() {
6875 let empty = std::fs::read_dir(&path)
6876 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6877 .next()
6878 .is_none();
6879 if empty {
6880 return Ok(None);
6881 }
6882 bail!(
6883 "the uninitialized gitlink at {} contains local files",
6884 path.display()
6885 );
6886 }
6887 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6888 if inside.trim() != "true" {
6889 bail!("the gitlink at {} is not a worktree", path.display());
6890 }
6891 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6892 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6893 spar_err!(
6894 "could not resolve the gitlink top level at {}: {e}",
6895 path.display()
6896 )
6897 })?;
6898 if top != canonical {
6899 bail!(
6900 "the gitlink at {} belongs to a different worktree",
6901 path.display()
6902 );
6903 }
6904 Ok(Some(canonical))
6905}
6906
6907fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6921 let root = std::fs::canonicalize(cwd)
6922 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6923 let mut allowed = BTreeSet::from([root.join(".git")]);
6924 let mut checkouts = BTreeSet::new();
6925 let mut repositories = vec![root.clone()];
6926 let mut visited = BTreeSet::new();
6927 while let Some(repository) = repositories.pop() {
6928 let canonical = std::fs::canonicalize(&repository)
6929 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6930 if !visited.insert(canonical.clone()) {
6931 bail!("submodule recursion revisited {}", canonical.display());
6932 }
6933 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6934 let (path, nested) = untracked_record(&raw, "untracked")?;
6935 if nested {
6936 checkouts.insert(canonical.join(path));
6937 }
6938 }
6939 for link in gitlinks(&canonical)? {
6940 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6941 continue;
6942 };
6943 allowed.insert(submodule.join(".git"));
6944 repositories.push(submodule);
6945 }
6946 }
6947
6948 let scan_root = root.clone();
6952 let mut directories = vec![root];
6953 while let Some(directory) = directories.pop() {
6954 let entries = match std::fs::read_dir(&directory) {
6955 Ok(entries) => entries,
6956 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6957 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6958 };
6959 for entry in entries {
6960 let entry = match entry {
6961 Ok(entry) => entry,
6962 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6963 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6964 };
6965 let path = entry.path();
6966 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6967 continue;
6968 }
6969 if entry.file_name() == OsStr::new(".git") {
6970 if !allowed.contains(&path) {
6971 return Ok(Some(path));
6972 }
6973 continue;
6974 }
6975 let kind = match entry.file_type() {
6976 Ok(kind) => kind,
6977 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6978 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6979 };
6980 if kind.is_dir() && !checkouts.contains(&path) {
6981 directories.push(path);
6982 }
6983 }
6984 }
6985 Ok(None)
6986}
6987
6988pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6989 if let Some(path) = unexpected_nested_git_entry(cwd)? {
6990 bail!(
6991 "the worktree contains a Git entry at {} that Git does not report as a nested \
6992 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6993 prove they are unchanged.",
6994 path.display()
6995 );
6996 }
6997 let root = std::fs::canonicalize(cwd)
6998 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6999 let mut repositories = BTreeMap::new();
7000 let mut visited = BTreeSet::new();
7001 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7002 Ok(GitState { repositories })
7003}
7004
7005fn collect_git_state(
7006 repository: &Path,
7007 prefix: &Path,
7008 visited: &mut BTreeSet<PathBuf>,
7009 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7010) -> Result<()> {
7011 let canonical = std::fs::canonicalize(repository)
7012 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7013 if !visited.insert(canonical.clone()) {
7014 bail!("submodule recursion revisited {}", canonical.display());
7015 }
7016 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7017 let head = head.trim().to_string();
7018 if head.is_empty() {
7019 bail!("git returned an empty head for {}", repository.display());
7020 }
7021 let unsafe_index_flags = unsafe_index_flags(repository)?;
7022 let tracked = tracked_entries(repository)?;
7023 let gitlinks = gitlinks(repository)?;
7024 if repositories
7025 .insert(
7026 prefix.to_path_buf(),
7027 RepositoryState {
7028 head,
7029 unsafe_index_flags,
7030 tracked,
7031 gitlinks: gitlinks
7032 .iter()
7033 .map(|link| (link.path.clone(), link.oid.clone()))
7034 .collect(),
7035 },
7036 )
7037 .is_some()
7038 {
7039 bail!("Git state contains duplicate repository path {:?}", prefix);
7040 }
7041
7042 for link in gitlinks {
7043 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7044 continue;
7045 };
7046 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7047 }
7048 Ok(())
7049}
7050
7051fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7052 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7053 if !listed.is_empty() && !listed.ends_with(&[0]) {
7054 bail!(
7055 "git returned an unterminated index-flag listing for {}",
7056 cwd.display()
7057 );
7058 }
7059 let mut unsafe_records = Vec::new();
7060 for record in listed
7061 .split(|byte| *byte == 0)
7062 .filter(|record| !record.is_empty())
7063 {
7064 if record.len() < 3 || record[1] != b' ' {
7065 bail!(
7066 "git returned a malformed index-flag record for {}",
7067 cwd.display()
7068 );
7069 }
7070 if record[0] != b'H' {
7071 unsafe_records.extend_from_slice(record);
7072 unsafe_records.push(0);
7073 }
7074 }
7075 Ok(unsafe_records)
7076}
7077
7078pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7079 safe_git_state(cwd).map(|_| ())
7080}
7081
7082pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7083 let state = git_state(cwd)?;
7084 if let Some((path, _repository)) = state
7085 .repositories
7086 .iter()
7087 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7088 {
7089 let label = if path.as_os_str().is_empty() {
7090 cwd.to_path_buf()
7091 } else {
7092 cwd.join(path)
7093 };
7094 bail!(
7095 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7096 SPAR cannot prove the working files are unchanged, so it was kept.",
7097 label.display()
7098 );
7099 }
7100 Ok(state)
7101}
7102
7103fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7104 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7105}
7106
7107fn repository_has_recoverable_work_with(
7108 cwd: &Path,
7109 include_ignored: bool,
7110 disposable: Disposable,
7111) -> Result<bool> {
7112 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7113 return Ok(true);
7114 }
7115 let mut visited = BTreeSet::new();
7116 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7117}
7118
7119fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7120 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7121 let git_dir = PathBuf::from(git_dir.trim());
7122 let git_dir = if git_dir.is_absolute() {
7123 git_dir
7124 } else {
7125 cwd.join(git_dir)
7126 };
7127 let git_dir = std::fs::canonicalize(&git_dir)
7128 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
7129 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
7130 Ok(_) => return Ok(true),
7131 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7132 Err(error) => {
7133 return Err(spar_err!(
7134 "could not inspect per-worktree configuration in {}: {error}",
7135 git_dir.display()
7136 ))
7137 }
7138 }
7139
7140 let orig_head = git_dir.join("ORIG_HEAD");
7141 match std::fs::symlink_metadata(&orig_head) {
7142 Ok(metadata) if metadata.is_file() => {
7143 let oid = std::fs::read_to_string(&orig_head)
7144 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
7145 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
7146 return Ok(true);
7147 };
7148 if !commit_has_shared_ref(cwd, &commit)? {
7149 return Ok(true);
7150 }
7151 }
7152 Ok(_) => return Ok(true),
7153 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7154 Err(error) => {
7155 return Err(spar_err!(
7156 "could not inspect {}: {error}",
7157 orig_head.display()
7158 ))
7159 }
7160 }
7161
7162 let edit_message = git_dir.join("COMMIT_EDITMSG");
7163 match std::fs::symlink_metadata(&edit_message) {
7164 Ok(metadata) if metadata.is_file() => {
7165 let draft = std::fs::read(&edit_message)
7166 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
7167 if draft != head_commit_message(cwd)? {
7168 return Ok(true);
7169 }
7170 }
7171 Ok(_) => return Ok(true),
7172 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7173 Err(error) => {
7174 return Err(spar_err!(
7175 "could not inspect {}: {error}",
7176 edit_message.display()
7177 ))
7178 }
7179 }
7180
7181 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
7182 return Ok(true);
7183 }
7184
7185 let local_refs = run_git_bytes(
7186 cwd,
7187 &[
7188 "for-each-ref",
7189 "--format=%(refname)",
7190 "refs/worktree",
7191 "refs/bisect",
7192 "refs/rewritten",
7193 ],
7194 )?;
7195 if !local_refs.is_empty() {
7196 return Ok(true);
7197 }
7198
7199 for entry in std::fs::read_dir(&git_dir)
7203 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
7204 {
7205 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
7206 let known = matches!(
7207 entry.file_name().to_str(),
7208 Some(
7209 "HEAD"
7210 | "ORIG_HEAD"
7211 | "COMMIT_EDITMSG"
7212 | "FETCH_HEAD"
7213 | "commondir"
7214 | "gitdir"
7215 | "index"
7216 | "logs"
7217 | "refs"
7218 )
7219 );
7220 if !known {
7221 return Ok(true);
7222 }
7223 }
7224
7225 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7226 if !commit_has_shared_ref(cwd, head.trim())? {
7227 return Ok(true);
7228 }
7229 Ok(false)
7230}
7231
7232fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
7233 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
7234 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
7235 bail!(
7236 "git returned a commit without a message separator in {}",
7237 cwd.display()
7238 );
7239 };
7240 Ok(commit[split + 2..].to_vec())
7241}
7242
7243fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
7244 let metadata = match std::fs::symlink_metadata(logs) {
7245 Ok(metadata) => metadata,
7246 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
7247 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
7248 };
7249 if !metadata.is_dir() {
7250 return Ok(true);
7251 }
7252 let mut files = Vec::new();
7253 let mut directories = vec![logs.to_path_buf()];
7254 while let Some(directory) = directories.pop() {
7255 for entry in std::fs::read_dir(&directory)
7256 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
7257 {
7258 let entry =
7259 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
7260 let path = entry.path();
7261 let kind = entry
7262 .file_type()
7263 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
7264 if kind.is_dir() {
7265 directories.push(path);
7266 } else if kind.is_file() {
7267 files.push(path);
7268 } else {
7269 return Ok(true);
7270 }
7271 }
7272 }
7273
7274 let mut commits = BTreeSet::new();
7275 for path in files {
7276 if !collect_reflog_commits(cwd, &path, &mut commits)? {
7277 return Ok(true);
7278 }
7279 }
7280 for commit in commits {
7281 if !commit_has_shared_ref(cwd, &commit)? {
7282 return Ok(true);
7283 }
7284 }
7285 Ok(false)
7286}
7287
7288fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
7292 let common = common_git_dir(cwd)?;
7293 let reflog = common.join("logs").join(refname);
7294 let metadata = match std::fs::symlink_metadata(&reflog) {
7295 Ok(metadata) => metadata,
7296 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
7297 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
7298 };
7299 if !metadata.is_file() {
7300 return Ok(false);
7301 }
7302 let mut commits = BTreeSet::new();
7303 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
7304 return Ok(false);
7305 }
7306 for commit in commits {
7307 if is_ancestor(cwd, &commit, durable_tip)?
7308 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
7309 {
7310 continue;
7311 }
7312 return Ok(false);
7313 }
7314 Ok(true)
7315}
7316
7317fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
7318 let file = std::fs::File::open(path)
7319 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7320 for line in std::io::BufReader::new(file).lines() {
7321 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7322 let mut fields = line.splitn(3, ' ');
7323 let Some(old) = fields.next() else {
7324 return Ok(false);
7325 };
7326 let Some(new) = fields.next() else {
7327 return Ok(false);
7328 };
7329 if fields.next().is_none() {
7330 return Ok(false);
7331 }
7332 for oid in [old, new] {
7333 if oid.bytes().all(|byte| byte == b'0') {
7334 continue;
7335 }
7336 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
7337 return Ok(false);
7338 };
7339 commits.insert(commit);
7340 }
7341 }
7342 Ok(true)
7343}
7344
7345fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
7346 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
7347 let path = PathBuf::from(raw.trim());
7348 let path = if path.is_absolute() {
7349 path
7350 } else {
7351 cwd.join(path)
7352 };
7353 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
7354}
7355
7356fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
7357 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
7358 let output = proc::exec(
7359 &argv,
7360 &ExecOpts::new()
7361 .cwd(cwd)
7362 .timeout_secs(30)
7363 .check(false)
7364 .stop_descendants(true),
7365 )?;
7366 match output.code {
7367 0 => Ok(true),
7368 1 => Ok(false),
7369 _ => bail!("{}", proc::failure_message(&argv, &output)),
7370 }
7371}
7372
7373fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
7374 let commit = format!("{oid}^{{commit}}");
7375 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
7376 let output = proc::exec(
7377 &argv,
7378 &ExecOpts::new()
7379 .cwd(cwd)
7380 .timeout_secs(30)
7381 .check(false)
7382 .stop_descendants(true),
7383 )?;
7384 if output.code != 0 {
7385 return Ok(None);
7386 }
7387 let oid = output.stdout.trim();
7388 if oid.is_empty() {
7389 return Ok(None);
7390 }
7391 Ok(Some(oid.to_string()))
7392}
7393
7394fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
7395 commit_has_shared_ref_except(cwd, oid, None)
7396}
7397
7398fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
7399 let contains = format!("--contains={oid}");
7400 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
7401 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
7402 !record.is_empty()
7403 && !record.starts_with(b"refs/worktree/")
7404 && !record.starts_with(b"refs/bisect/")
7405 && !record.starts_with(b"refs/rewritten/")
7406 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
7407 }))
7408}
7409
7410fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
7425 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
7426 for raw in &ordinary {
7427 let (path, _) = untracked_record(raw, "untracked")?;
7428 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
7429 continue;
7430 }
7431 return Ok(true);
7432 }
7433 let listed = untracked_listing(
7434 cwd,
7435 &[
7436 "ls-files",
7437 "--others",
7438 "--ignored",
7439 "--exclude-standard",
7440 "-z",
7441 ],
7442 )?;
7443 for raw in listed {
7444 let (path, nested) = untracked_record(&raw, "ignored")?;
7445 if nested {
7449 return Ok(true);
7450 }
7451 if disposable == Disposable::BuildOutput
7452 && !is_generated_artifact(&path)
7453 && !is_desktop_metadata(&path)
7454 {
7455 return Ok(true);
7456 }
7457 }
7458 Ok(false)
7459}
7460
7461fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
7462 let listed = run_git_bytes(cwd, args)?;
7463 if !listed.is_empty() && !listed.ends_with(&[0]) {
7464 bail!(
7465 "git returned an unterminated untracked-file list for {}",
7466 cwd.display()
7467 );
7468 }
7469 Ok(listed
7470 .split(|byte| *byte == 0)
7471 .filter(|raw| !raw.is_empty())
7472 .map(|raw| raw.to_vec())
7473 .collect())
7474}
7475
7476fn repository_has_recoverable_work_inner(
7477 cwd: &Path,
7478 include_ignored: bool,
7479 disposable: Disposable,
7480 visited: &mut BTreeSet<PathBuf>,
7481) -> Result<bool> {
7482 let canonical = std::fs::canonicalize(cwd)
7483 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7484 if !visited.insert(canonical.clone()) {
7485 bail!("submodule recursion revisited {}", canonical.display());
7486 }
7487 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
7488 return Ok(true);
7489 }
7490 if !unsafe_index_flags(cwd)?.is_empty() {
7491 return Ok(true);
7492 }
7493 if attributes_may_be_modified(cwd)? {
7494 return Ok(true);
7495 }
7496 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
7497 return Ok(true);
7498 }
7499 if include_ignored {
7500 let index = index_entries(cwd)?
7501 .into_iter()
7502 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7503 .collect::<BTreeMap<_, _>>();
7504 let head = tree_entries(cwd, "HEAD")?
7505 .into_iter()
7506 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7507 .collect::<BTreeMap<_, _>>();
7508 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
7509 return Ok(true);
7510 }
7511 let tracked = tracked_entries(cwd)?;
7512 let effective = check_attributes(cwd, tracked.keys().cloned())?;
7513 let config = CheckoutConfig::read(cwd)?;
7514 for (path, entry) in tracked {
7515 let Some(worktree) = entry.worktree else {
7516 return Ok(true);
7517 };
7518 let attributes = effective.get(&path).ok_or_else(|| {
7519 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
7520 })?;
7521 if path_has_ambiguous_transform(&config, attributes)? {
7522 return Ok(true);
7523 }
7524 let symlink_file = entry.index_mode == "120000"
7525 && worktree.mode == "100644"
7526 && worktree.raw_oid == entry.index_oid
7527 && config.symlinks == Some(false);
7528 if worktree.mode != entry.index_mode && !symlink_file {
7529 return Ok(true);
7530 }
7531 if entry.index_mode == "120000" {
7532 if worktree.raw_oid != entry.index_oid {
7533 return Ok(true);
7534 }
7535 continue;
7536 }
7537 #[cfg(unix)]
7538 {
7539 let expected = if entry.index_mode == "100755" {
7540 0o755
7541 } else {
7542 0o644
7543 };
7544 if worktree.permissions != expected {
7545 return Ok(true);
7546 }
7547 }
7548 if allows_expected_crlf(&config, attributes)? {
7549 let (normalized, every_lf_was_crlf) =
7550 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
7551 if !every_lf_was_crlf || normalized != entry.index_oid {
7552 return Ok(true);
7553 }
7554 } else if worktree.raw_oid != entry.index_oid {
7555 return Ok(true);
7556 }
7557 }
7558 } else {
7559 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
7560 if !run_git_bytes(cwd, &args)?.is_empty() {
7561 return Ok(true);
7562 }
7563 }
7564 for link in gitlinks(cwd)? {
7565 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
7566 continue;
7567 };
7568 if include_ignored {
7573 return Ok(true);
7574 }
7575 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7576 if head.trim() != link.oid {
7577 return Ok(true);
7578 }
7579 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
7580 {
7581 return Ok(true);
7582 }
7583 }
7584 Ok(false)
7585}
7586
7587pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
7588 repository_has_recoverable_work(cwd, false)
7589}
7590
7591fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
7592 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
7593 Ok(!run_git_bytes(cwd, &args)?.is_empty())
7594}
7595
7596#[cfg(unix)]
7597fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7598 use std::os::unix::ffi::OsStringExt;
7599 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
7600}
7601
7602#[cfg(not(unix))]
7603fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7604 String::from_utf8(raw.to_vec())
7605 .map(PathBuf::from)
7606 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
7607}
7608
7609fn vanished_untracked_file() -> UntrackedFile {
7625 UntrackedFile {
7626 kind: 0,
7627 len: 0,
7628 modified: None,
7629 created: None,
7630 readonly: false,
7631 symlink_target: None,
7632 #[cfg(unix)]
7633 device: 0,
7634 #[cfg(unix)]
7635 inode: 0,
7636 #[cfg(unix)]
7637 mode: 0,
7638 #[cfg(unix)]
7639 change_seconds: 0,
7640 #[cfg(unix)]
7641 change_nanoseconds: 0,
7642 }
7643}
7644
7645fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
7646 let metadata = match std::fs::symlink_metadata(path) {
7647 Ok(metadata) => metadata,
7648 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7649 Err(e) => {
7650 return Err(spar_err!(
7651 "could not inspect the nested repository at {}: {e}",
7652 path.display()
7653 ))
7654 }
7655 };
7656 if !metadata.is_dir() {
7657 bail!(
7658 "git reported {} as a nested repository, but it is not a directory",
7659 path.display()
7660 );
7661 }
7662 if std::fs::symlink_metadata(path.join(".git")).is_err() {
7663 bail!(
7664 "git reported {} as a nested repository, but it has no Git entry",
7665 path.display()
7666 );
7667 }
7668 #[cfg(unix)]
7669 {
7670 use std::os::unix::fs::MetadataExt;
7671 Ok(UntrackedFile {
7672 kind: 3,
7673 len: 0,
7674 modified: None,
7675 created: metadata.created().ok(),
7676 readonly: metadata.permissions().readonly(),
7677 symlink_target: None,
7678 device: metadata.dev(),
7679 inode: metadata.ino(),
7680 mode: metadata.mode(),
7681 change_seconds: 0,
7682 change_nanoseconds: 0,
7683 })
7684 }
7685 #[cfg(not(unix))]
7686 {
7687 Ok(UntrackedFile {
7688 kind: 3,
7689 len: 0,
7690 modified: None,
7691 created: metadata.created().ok(),
7692 readonly: metadata.permissions().readonly(),
7693 symlink_target: None,
7694 })
7695 }
7696}
7697
7698fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7699 let metadata = match std::fs::symlink_metadata(path) {
7700 Ok(metadata) => metadata,
7701 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7702 Err(e) => {
7703 return Err(spar_err!(
7704 "could not inspect untracked file {}: {e}",
7705 path.display()
7706 ))
7707 }
7708 };
7709 let kind = if metadata.file_type().is_symlink() {
7710 2
7711 } else if metadata.is_file() {
7712 1
7713 } else {
7714 bail!(
7715 "untracked path {} is not a regular file or symlink",
7716 path.display()
7717 );
7718 };
7719 let symlink_target = if kind == 2 {
7720 match std::fs::read_link(path) {
7721 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7722 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7723 return Ok(vanished_untracked_file())
7724 }
7725 Err(e) => {
7726 return Err(spar_err!(
7727 "could not read untracked symlink {}: {e}",
7728 path.display()
7729 ))
7730 }
7731 }
7732 } else {
7733 None
7734 };
7735 #[cfg(unix)]
7736 {
7737 use std::os::unix::fs::MetadataExt;
7738 Ok(UntrackedFile {
7739 kind,
7740 len: metadata.len(),
7741 modified: metadata.modified().ok(),
7742 created: metadata.created().ok(),
7743 readonly: metadata.permissions().readonly(),
7744 symlink_target,
7745 device: metadata.dev(),
7746 inode: metadata.ino(),
7747 mode: metadata.mode(),
7748 change_seconds: metadata.ctime(),
7749 change_nanoseconds: metadata.ctime_nsec(),
7750 })
7751 }
7752 #[cfg(not(unix))]
7753 {
7754 Ok(UntrackedFile {
7755 kind,
7756 len: metadata.len(),
7757 modified: metadata.modified().ok(),
7758 created: metadata.created().ok(),
7759 readonly: metadata.permissions().readonly(),
7760 symlink_target,
7761 })
7762 }
7763}
7764
7765#[cfg(unix)]
7766fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7767 use std::os::unix::ffi::OsStrExt;
7768 Ok(value.as_bytes().to_vec())
7769}
7770
7771#[cfg(not(unix))]
7772fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7773 value
7774 .to_str()
7775 .map(|value| value.as_bytes().to_vec())
7776 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7777}
7778
7779#[cfg(unix)]
7780fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7781 use std::os::unix::fs::MetadataExt;
7782 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7783}
7784
7785#[cfg(not(unix))]
7786fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7787 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7788}
7789
7790#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7791pub struct BranchRecord {
7792 pub kind: String,
7793 pub number: i64,
7794}
7795
7796pub fn review_ref(number: i64) -> String {
7799 format!("refs/spar/pr-{number}")
7800}
7801
7802pub fn is_finished(state: &str) -> bool {
7803 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7804}
7805
7806pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7813 if let Some(parent) = path.parent() {
7814 std::fs::create_dir_all(parent)
7815 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7816 }
7817 let tmp = path.with_extension(format!(
7820 "{}.tmp",
7821 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7822 ));
7823 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7824 std::fs::rename(&tmp, path)
7825 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7826 Ok(())
7827}
7828
7829pub fn has_heading(text: &str, title: &str) -> bool {
7836 let wanted = title.trim().to_lowercase();
7837 if wanted.is_empty() {
7838 return false;
7839 }
7840 text.lines()
7841 .filter_map(|line| line.trim().strip_prefix("## "))
7842 .any(|heading| heading.trim().to_lowercase() == wanted)
7843}
7844
7845pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7848 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7849}
7850
7851#[derive(Deserialize)]
7853#[serde(rename_all = "camelCase")]
7854pub struct LinkedPrRow {
7855 number: i64,
7856 #[serde(default)]
7857 url: String,
7858 #[serde(default)]
7859 title: String,
7860 #[serde(default)]
7861 closing_issues_references: Vec<IssueRef>,
7862}
7863
7864pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7871 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7872}
7873
7874pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7876 rows.iter()
7877 .find(|row| {
7878 row.closing_issues_references
7879 .iter()
7880 .any(|linked| linked.number == issue)
7881 })
7882 .map(|row| PrRef {
7883 number: row.number,
7884 url: row.url.clone(),
7885 title: row.title.clone(),
7886 })
7887}
7888
7889pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7891 pick_linked_pr(&linked_pr_rows(json)?, issue)
7892}
7893
7894pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7901 if text.trim().is_empty() {
7902 return Err(spar_err!("GitHub returned no comment data"));
7903 }
7904 let mut out = Vec::new();
7905 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7906 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7907 Value::Array(items) => out.extend(items),
7908 _ => return Err(spar_err!("unexpected non-array comment page")),
7909 }
7910 }
7911 Ok(out)
7912}
7913
7914pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7915 let mut out = Vec::new();
7916 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7917 match value {
7918 Ok(Value::Array(items)) => out.extend(items),
7919 Ok(other) => out.push(other),
7920 Err(_) => break,
7921 }
7922 }
7923 out
7924}
7925
7926pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7934 body.trim_start().starts_with(STATE_MARKER)
7935 && !viewer.trim().is_empty()
7936 && author.trim().eq_ignore_ascii_case(viewer.trim())
7937 && parse_state_comment(body).is_some()
7938}
7939
7940pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7943 let marker = body.find(STATE_MARKER)?;
7944 let start = body[marker..].find('{')? + marker;
7945 let end = body.rfind('}')?;
7946 if end <= start {
7947 return None;
7948 }
7949 match serde_json::from_str(&body[start..=end]) {
7950 Ok(state) => Some(state),
7951 Err(_) => {
7952 logdim!("found a spar state comment but could not parse it");
7953 None
7954 }
7955 }
7956}
7957
7958fn choose_state_for_head(
7959 candidates: Vec<PersistedState>,
7960 actual_head: &str,
7961) -> Option<PersistedState> {
7962 let matching: Vec<PersistedState> = candidates
7963 .iter()
7964 .filter(|state| state.pr_head == actual_head)
7965 .cloned()
7966 .collect();
7967 if !matching.is_empty() {
7968 return newest_state(matching);
7969 }
7970 newest_state(candidates)
7971}
7972
7973fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7974 candidates.into_iter().reduce(|best, candidate| {
7975 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7976 candidate
7977 } else {
7978 best
7983 }
7984 })
7985}
7986
7987pub fn self_binary() -> Result<PathBuf> {
7993 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7994 let path = PathBuf::from(path);
7995 if proc::is_executable(&path) {
7996 return Ok(path);
7997 }
7998 bail!(
7999 "SPAR_SELF_BIN is set to {}, which is not executable",
8000 path.display()
8001 );
8002 }
8003 std::env::current_exe()
8004 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8005}
8006
8007fn bool_env(value: bool) -> &'static str {
8008 if value {
8009 "1"
8010 } else {
8011 "0"
8012 }
8013}
8014
8015pub fn sh_quote(text: &str) -> String {
8018 format!("'{}'", text.replace('\'', r"'\''"))
8019}
8020
8021pub fn style_from_env() -> Style {
8024 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8025 Style {
8026 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8027 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8028 ..Style::permissive()
8029 }
8030}
8031
8032#[cfg(test)]
8033mod tests {
8034 use super::*;
8035 use crate::config::StateStore;
8036 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8037 use std::process::Command;
8038
8039 fn repo_for_titles() -> Repo {
8040 Repo {
8041 root: PathBuf::from("/nonexistent"),
8042 style: Style::default(),
8043 branch_prefix: String::new(),
8044 state_store: StateStore::Local,
8045 followups: crate::config::Followups::Issues,
8046 drafts: Drafts::Never,
8047 viewer: OnceLock::new(),
8048 checkpoints: Mutex::new(BTreeMap::new()),
8049 writes: WriteStats::default(),
8050 }
8051 }
8052
8053 #[test]
8054 fn write_results_accumulate_for_the_run() {
8055 let repo = repo_for_titles();
8056
8057 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8058 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8059
8060 assert_eq!(
8061 WriteSummary {
8062 attempted: 2,
8063 failed: 1,
8064 },
8065 repo.write_summary()
8066 );
8067 }
8068
8069 #[test]
8070 fn only_failed_write_preflights_join_the_summary() {
8071 let repo = repo_for_titles();
8072
8073 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8074 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8075
8076 assert_eq!(
8077 WriteSummary {
8078 attempted: 1,
8079 failed: 1,
8080 },
8081 repo.write_summary()
8082 );
8083 }
8084
8085 #[test]
8086 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8087 let repo = repo_for_titles();
8088
8089 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8090 assert_eq!(
8091 WriteSummary {
8092 attempted: 1,
8093 failed: 1,
8094 },
8095 repo.write_summary()
8096 );
8097 }
8098
8099 #[test]
8100 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8101 let mut repo = repo_for_titles();
8102 repo.followups = Followups::Local;
8103
8104 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8105 assert_eq!(WriteSummary::default(), repo.write_summary());
8106 }
8107
8108 #[test]
8109 fn a_failed_remote_state_read_stops_before_state_mutation() {
8110 let root = std::env::temp_dir().join(format!(
8111 "spar-state-preflight-{}-{}",
8112 std::process::id(),
8113 std::time::SystemTime::now()
8114 .duration_since(std::time::UNIX_EPOCH)
8115 .unwrap()
8116 .as_nanos()
8117 ));
8118 std::fs::create_dir_all(&root).unwrap();
8119 let _fixture = ReviewFixture { root: root.clone() };
8120 let mut repo = repo_for_titles();
8121 repo.root = root;
8122 repo.state_store = StateStore::Both;
8123 let state = PersistedState {
8124 version: 1,
8125 checkpoint: 4,
8126 round: 2,
8127 next_actor: "a".into(),
8128 status: Status::Pending,
8129 pr_head: "abc123".into(),
8130 ledger: Ledger::new(),
8131 filed: Vec::new(),
8132 open_findings: Vec::new(),
8133 disputes: Vec::new(),
8134 noted: Vec::new(),
8135 };
8136
8137 let error = repo
8138 .write_state_after_remote_read(
8139 7,
8140 &state,
8141 Err(crate::error::SparError::new("state comments unavailable")),
8142 )
8143 .unwrap_err();
8144
8145 assert!(error.to_string().contains("state comments unavailable"));
8146 assert!(!repo.state_path(7).exists());
8147 assert_eq!(0, repo.remembered_checkpoint(7));
8148 assert_eq!(
8149 WriteSummary {
8150 attempted: 1,
8151 failed: 1,
8152 },
8153 repo.write_summary()
8154 );
8155 }
8156
8157 #[test]
8158 fn only_known_build_and_cache_directories_are_generated_artifacts() {
8159 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
8160 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
8161 assert!(is_generated_artifact(Path::new(
8162 "package/node_modules/dependency/file.js"
8163 )));
8164 assert!(!is_generated_artifact(Path::new(
8165 "distribution/required-package.js"
8166 )));
8167 assert!(!is_generated_artifact(Path::new(
8168 "generated/required-fixture.txt"
8169 )));
8170 assert!(!is_generated_artifact(Path::new("local.env")));
8171 }
8172
8173 #[test]
8174 fn only_finder_state_files_are_desktop_metadata() {
8175 assert!(is_desktop_metadata(Path::new(".DS_Store")));
8176 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
8177 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
8178 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
8179 }
8180
8181 struct ReviewFixture {
8182 root: PathBuf,
8183 }
8184
8185 impl Drop for ReviewFixture {
8186 fn drop(&mut self) {
8187 let _ = std::fs::remove_dir_all(&self.root);
8188 }
8189 }
8190
8191 fn test_git(cwd: &Path, args: &[&str]) -> String {
8192 let output = Command::new("git")
8193 .args(args)
8194 .current_dir(cwd)
8195 .output()
8196 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
8197 assert!(
8198 output.status.success(),
8199 "git {args:?} failed: {}",
8200 String::from_utf8_lossy(&output.stderr)
8201 );
8202 String::from_utf8_lossy(&output.stdout).into_owned()
8203 }
8204
8205 fn review_fixture(
8206 tag: &str,
8207 number: i64,
8208 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
8209 use std::sync::atomic::{AtomicU32, Ordering};
8210 static NEXT: AtomicU32 = AtomicU32::new(0);
8211 let id = NEXT.fetch_add(1, Ordering::Relaxed);
8212 let root =
8213 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
8214 let origin = root.join("origin.git");
8215 let work = root.join("work");
8216 std::fs::create_dir_all(&origin).unwrap();
8217 std::fs::create_dir_all(&work).unwrap();
8218 test_git(&origin, &["init", "--bare", "-b", "main"]);
8219 test_git(&work, &["init", "-b", "main"]);
8220 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
8221 test_git(&work, &["config", "user.name", "spar test"]);
8222 test_git(&work, &["config", "commit.gpgsign", "false"]);
8223 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
8224 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
8225 std::fs::write(work.join("README.md"), "seed\n").unwrap();
8226 std::fs::write(work.join("data.txt"), "old\n").unwrap();
8227 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
8228 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
8229 test_git(&work, &["add", "."]);
8230 test_git(&work, &["commit", "-m", "seed"]);
8231 test_git(
8232 &work,
8233 &["remote", "add", "origin", origin.to_str().unwrap()],
8234 );
8235 test_git(&work, &["push", "-u", "origin", "main"]);
8236 test_git(
8237 &work,
8238 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
8239 );
8240 let cfg = crate::config::parse(
8241 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
8242 )
8243 .unwrap();
8244 let repo = Repo::open(&work, &cfg).unwrap();
8245 let path = repo.worktree_for_pr_head(number).unwrap();
8246 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8247 (ReviewFixture { root }, repo, path, checkpoint)
8248 }
8249
8250 #[test]
8251 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
8252 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
8253
8254 repo.release_review_worktree_checked(901, &checkpoint)
8255 .unwrap();
8256
8257 assert!(!path.exists());
8258 }
8259
8260 #[test]
8261 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
8262 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
8263 let work = fixture.root.join("work");
8264 let seed = test_git(&work, &["rev-parse", "HEAD"]);
8265 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
8266 let publish = |message: &str| {
8269 let oid = test_git(
8270 &work,
8271 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
8272 );
8273 let oid = oid.trim().to_string();
8274 test_git(
8275 &work,
8276 &[
8277 "push",
8278 "--force",
8279 "origin",
8280 &format!("{oid}:refs/pull/923/head"),
8281 ],
8282 );
8283 oid
8284 };
8285 let reviewed = publish("reviewed head");
8286 repo.worktree_for_pr_head(923).unwrap();
8287 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
8288
8289 let rewritten = publish("rewritten head");
8290 repo.worktree_for_pr_head(923).unwrap();
8291
8292 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
8293 }
8294
8295 #[test]
8296 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
8297 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
8298 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
8299 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
8300 test_git(&path, &["add", "recovery.txt"]);
8301 test_git(&path, &["commit", "-m", "recovery commit"]);
8302 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
8303 test_git(&path, &["reset", "--hard", "main"]);
8304
8305 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
8306 test_git(
8307 &path,
8308 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8309 );
8310 }
8311
8312 #[test]
8313 fn a_review_ref_reflog_only_commit_prevents_deletion() {
8314 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
8315 let local_ref = review_ref(921);
8316 let original = test_git(&path, &["rev-parse", &local_ref]);
8317 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
8318 let recovery = test_git(
8319 &path,
8320 &[
8321 "commit-tree",
8322 tree.trim(),
8323 "-p",
8324 original.trim(),
8325 "-m",
8326 "review ref recovery",
8327 ],
8328 );
8329 test_git(
8330 &path,
8331 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
8332 );
8333 test_git(
8334 &path,
8335 &["update-ref", &local_ref, original.trim(), recovery.trim()],
8336 );
8337
8338 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
8339 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
8340 test_git(
8341 &path,
8342 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8343 );
8344 }
8345
8346 #[test]
8347 fn an_unpublished_commit_message_draft_is_recoverable() {
8348 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
8349 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
8350 let git_dir = if raw.is_absolute() {
8351 raw
8352 } else {
8353 path.join(raw)
8354 };
8355 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
8356
8357 assert!(repository_has_recoverable_work(&path, true).unwrap());
8358 assert_eq!(
8359 "unique recovery draft\n",
8360 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
8361 );
8362 }
8363
8364 #[test]
8365 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
8366 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
8367 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8368
8369 let error = repo
8370 .release_review_worktree_checked(902, &checkpoint)
8371 .unwrap_err();
8372
8373 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8374 assert!(error.to_string().contains("kept for recovery"), "{error}");
8375 assert_eq!(
8376 "recover me\n",
8377 std::fs::read_to_string(path.join("README.md")).unwrap()
8378 );
8379 repo.release_review_worktree(902);
8380 }
8381
8382 #[test]
8383 fn a_review_commit_is_retained_after_a_checked_read() {
8384 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
8385 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
8386 test_git(&path, &["add", "review-note.txt"]);
8387 test_git(&path, &["commit", "-m", "local review recovery"]);
8388 let head = test_git(&path, &["rev-parse", "HEAD"]);
8389
8390 let error = repo
8391 .release_review_worktree_checked(903, &checkpoint)
8392 .unwrap_err();
8393
8394 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8395 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
8396 assert_eq!(
8397 "recover me\n",
8398 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
8399 );
8400 repo.release_review_worktree(903);
8401 }
8402
8403 #[test]
8407 fn an_ignored_review_file_is_retained_after_a_checked_read() {
8408 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
8409 std::fs::create_dir_all(path.join("generated")).unwrap();
8410 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
8411
8412 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8413 .expect("its own output is not a change");
8414 let error = repo
8415 .release_review_worktree_checked(904, &checkpoint)
8416 .unwrap_err();
8417
8418 assert!(error.to_string().contains("recoverable"), "{error}");
8419 assert_eq!(
8420 "recover me\n",
8421 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
8422 );
8423 repo.release_review_worktree(904);
8424 }
8425
8426 #[test]
8427 fn a_preexisting_ignored_review_file_change_is_retained() {
8433 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
8434 std::fs::create_dir_all(path.join("generated")).unwrap();
8435 let ignored = path.join("generated/recovery.txt");
8436 std::fs::write(&ignored, "before\n").unwrap();
8437 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8438 std::fs::write(&ignored, "after!\n").unwrap();
8439
8440 let error = repo
8441 .release_review_worktree_checked(905, &checkpoint)
8442 .unwrap_err();
8443
8444 assert!(error.to_string().contains("recoverable"), "{error}");
8445 assert!(path.exists());
8446 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
8447 repo.release_review_worktree(905);
8448 }
8449
8450 #[test]
8451 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
8452 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
8453 std::fs::create_dir_all(path.join("generated")).unwrap();
8454 let ignored = path.join("generated/recovery.txt");
8455 std::fs::write(&ignored, "keep me\n").unwrap();
8456 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8457
8458 let error = repo
8459 .release_review_worktree_checked(906, &checkpoint)
8460 .unwrap_err();
8461
8462 assert!(error.to_string().contains("recoverable"), "{error}");
8463 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
8464 }
8465
8466 #[test]
8467 fn overwriting_a_preexisting_untracked_file_is_detected() {
8468 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
8469 let untracked = path.join("notes.txt");
8470 std::fs::write(&untracked, "before\n").unwrap();
8471 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8472 std::fs::write(&untracked, "after!\n").unwrap();
8473
8474 let error = repo
8475 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8476 .unwrap_err();
8477
8478 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8479 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
8480 }
8481
8482 #[test]
8483 fn an_assume_unchanged_edit_is_detected() {
8484 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
8485 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
8486 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
8487
8488 let error = repo
8489 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8490 .unwrap_err();
8491
8492 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8493 assert_eq!(
8494 "hidden\n",
8495 std::fs::read_to_string(path.join("README.md")).unwrap()
8496 );
8497 }
8498
8499 #[test]
8500 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
8501 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
8502 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8503 test_git(&path, &["add", "README.md"]);
8504 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8505
8506 let error = repo
8507 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8508 .unwrap_err();
8509
8510 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8511 assert_eq!(
8512 b"seed\r\n",
8513 std::fs::read(path.join("README.md")).unwrap().as_slice()
8514 );
8515 }
8516
8517 #[cfg(unix)]
8518 #[test]
8519 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
8520 use std::os::unix::fs::PermissionsExt;
8521
8522 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
8523 test_git(&path, &["config", "core.filemode", "false"]);
8524 let readme = path.join("README.md");
8525 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8526 permissions.set_mode(0o755);
8527 std::fs::set_permissions(&readme, permissions).unwrap();
8528 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8529
8530 let error = repo
8531 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8532 .unwrap_err();
8533
8534 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8535 assert_eq!(
8536 0o755,
8537 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8538 );
8539 }
8540
8541 #[test]
8542 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
8543 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
8544 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
8545 test_git(&path, &["add", ".gitattributes"]);
8546 test_git(&path, &["commit", "-m", "select data filter"]);
8547 let baseline = repo.worktree_baseline(&path).unwrap();
8548 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
8549
8550 assert!(repo
8551 .commit_pending_changes(&path, &baseline, "change data", "change data")
8552 .unwrap());
8553 let error = repo
8554 .refuse_unrepresented_tracked_changes(&path, &baseline)
8555 .unwrap_err();
8556
8557 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8558 assert_eq!(
8559 "secret: recover me\nnew\n",
8560 std::fs::read_to_string(path.join("data.txt")).unwrap()
8561 );
8562 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
8563 }
8564
8565 #[test]
8566 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
8567 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
8568 std::fs::create_dir_all(path.join("target")).unwrap();
8569 let untracked = path.join("target/user.yaml");
8570 std::fs::write(&untracked, "user data\n").unwrap();
8571 let baseline = repo.worktree_baseline(&path).unwrap();
8572 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8573
8574 assert!(repo
8575 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8576 .unwrap());
8577
8578 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
8579 assert_eq!(
8580 "?? target/user.yaml\n",
8581 test_git(&path, &["status", "--short", "--untracked-files=all"])
8582 );
8583 assert!(test_git(
8584 &path,
8585 &[
8586 "ls-tree",
8587 "-r",
8588 "--name-only",
8589 "HEAD",
8590 "--",
8591 "target/user.yaml"
8592 ]
8593 )
8594 .is_empty());
8595 }
8596
8597 #[test]
8598 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
8599 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
8600 std::fs::create_dir_all(path.join("target")).unwrap();
8601 let untracked = path.join("target/user.yaml");
8602 std::fs::write(&untracked, "before\n").unwrap();
8603 let baseline = repo.worktree_baseline(&path).unwrap();
8604 let before = test_git(&path, &["rev-parse", "HEAD"]);
8605 std::fs::write(&untracked, "after\n").unwrap();
8606 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8607
8608 let error = repo
8609 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8610 .unwrap_err();
8611
8612 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8613 assert!(error.to_string().contains("target/user.yaml"), "{error}");
8614 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8615 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
8616 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
8617 }
8618
8619 #[test]
8620 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
8621 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
8622 let baseline = repo.worktree_baseline(&path).unwrap();
8623 std::fs::create_dir_all(path.join("target")).unwrap();
8624 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
8625
8626 assert!(repo
8627 .commit_pending_changes(&path, &baseline, "add file", "add file")
8628 .unwrap());
8629
8630 assert_eq!(
8631 "new file\n",
8632 test_git(&path, &["show", "HEAD:target/new.txt"])
8633 );
8634 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8635 }
8636
8637 #[test]
8638 fn a_managed_commit_accepts_a_custom_output_rebuild() {
8639 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
8640 exclude_paths(&repo, &["manager/public/"]);
8641 let output = path.join("manager/public/assets");
8642 std::fs::create_dir_all(&output).unwrap();
8643 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
8644 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
8645 let baseline = repo.worktree_baseline(&path).unwrap();
8646 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8647 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
8648 std::fs::remove_file(output.join("old.js")).unwrap();
8649 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
8650 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
8651
8652 assert!(repo
8653 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8654 .unwrap());
8655 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8656 repo.refuse_changed_existing_untracked(&path, &baseline)
8657 .unwrap();
8658 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
8659 .unwrap();
8660
8661 assert_eq!(
8662 "tracked change\n",
8663 test_git(&path, &["show", "HEAD:README.md"])
8664 );
8665 assert_eq!(
8666 "new source\n",
8667 test_git(&path, &["show", "HEAD:new-source.txt"])
8668 );
8669 assert!(test_git(
8670 &path,
8671 &[
8672 "ls-tree",
8673 "-r",
8674 "--name-only",
8675 "HEAD",
8676 "--",
8677 "manager/public"
8678 ]
8679 )
8680 .is_empty());
8681 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8682 assert!(!output.join("old.js").exists());
8683 assert_eq!(
8684 "new styles\n",
8685 std::fs::read_to_string(output.join("index.css")).unwrap()
8686 );
8687 assert!(repository_has_recoverable_work(&path, true).unwrap());
8688 repo.release_review_worktree(912);
8689 assert!(output.join("index-DUym-Rfj.js").exists());
8690 }
8691
8692 #[test]
8693 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
8694 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
8695 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
8696 let baseline = repo.worktree_baseline(&path).unwrap();
8697 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
8698 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
8699
8700 let error = repo
8701 .commit_pending_changes(&path, &baseline, "change", "change")
8702 .unwrap_err();
8703
8704 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8705 assert!(error.to_string().contains("notes.txt"), "{error}");
8706 }
8707
8708 #[test]
8709 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
8710 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
8711 let baseline = repo.worktree_baseline(&path).unwrap();
8712 let nested = path.join("generated/repro");
8713 std::fs::create_dir_all(&nested).unwrap();
8714 test_git(&nested, &["init"]);
8715 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8716
8717 assert!(repo
8718 .commit_pending_changes(&path, &baseline, "change", "change")
8719 .unwrap());
8720 assert!(!repo.has_uncommitted_changes(&path).unwrap());
8721 assert!(repository_has_recoverable_work(&path, true).unwrap());
8722 repo.release_review_worktree(927);
8723 assert!(nested.join(".git").exists());
8724 }
8725
8726 #[test]
8727 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
8728 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
8729 test_git(&path, &["config", "core.autocrlf", "true"]);
8730 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8731 assert_eq!(
8732 test_git(&path, &["hash-object", "README.md"]).trim(),
8733 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8734 );
8735
8736 assert!(repository_has_recoverable_work(&path, true).unwrap());
8737 assert_eq!(
8738 "seed\n",
8739 std::fs::read_to_string(path.join("README.md")).unwrap()
8740 );
8741 }
8742
8743 #[test]
8744 fn autocrlf_input_overrides_a_crlf_core_eol() {
8745 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8746 test_git(&path, &["config", "core.autocrlf", "input"]);
8747 test_git(&path, &["config", "core.eol", "crlf"]);
8748 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8749 assert_eq!(
8750 test_git(&path, &["hash-object", "README.md"]).trim(),
8751 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8752 );
8753
8754 assert!(repository_has_recoverable_work(&path, true).unwrap());
8755 assert_eq!(
8756 b"seed\r\n",
8757 std::fs::read(path.join("README.md")).unwrap().as_slice()
8758 );
8759 }
8760
8761 #[cfg(unix)]
8762 #[test]
8763 fn a_non_executable_permission_change_is_recoverable() {
8764 use std::os::unix::fs::PermissionsExt;
8765
8766 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8767 let readme = path.join("README.md");
8768 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8769 permissions.set_mode(0o600);
8770 std::fs::set_permissions(&readme, permissions).unwrap();
8771 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8772
8773 let error = repo
8774 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8775 .unwrap_err();
8776
8777 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8778 assert!(repository_has_recoverable_work(&path, true).unwrap());
8779 assert_eq!(
8780 0o600,
8781 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8782 );
8783 }
8784
8785 #[cfg(unix)]
8786 #[test]
8787 fn a_managed_commit_skips_signing_and_hooks() {
8788 use std::os::unix::fs::PermissionsExt;
8789
8790 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8791 let common = common_git_dir(&path).unwrap();
8792 let hook = common.join("hooks/pre-commit");
8793 let marker = fixture.root.join("hook-ran");
8794 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8795 std::fs::write(
8796 &hook,
8797 format!(
8798 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8799 sh_quote(marker.to_str().unwrap())
8800 ),
8801 )
8802 .unwrap();
8803 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8804 permissions.set_mode(0o755);
8805 std::fs::set_permissions(&hook, permissions).unwrap();
8806 test_git(&path, &["config", "commit.gpgsign", "true"]);
8807 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8808 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8809 test_git(&path, &["add", "managed.txt"]);
8810
8811 repo.commit_staged_changes(&path, "record managed change")
8812 .unwrap();
8813
8814 assert!(!marker.exists());
8815 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8816 }
8817
8818 #[test]
8819 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8820 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8821 std::fs::write(
8822 path.join(".gitattributes"),
8823 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8824 )
8825 .unwrap();
8826 test_git(&path, &["add", ".gitattributes"]);
8827 test_git(&path, &["commit", "-m", "select automatic text"]);
8828 test_git(&path, &["config", "core.autocrlf", "true"]);
8829
8830 assert!(repository_has_recoverable_work(&path, true).unwrap());
8831 }
8832
8833 #[test]
8834 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8835 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8836 std::fs::write(
8837 path.join(".gitattributes"),
8838 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8839 )
8840 .unwrap();
8841 test_git(&path, &["add", ".gitattributes"]);
8842 test_git(&path, &["commit", "-m", "select ident expansion"]);
8843 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8844
8845 assert!(repository_has_recoverable_work(&path, true).unwrap());
8846 }
8847
8848 #[test]
8854 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8855 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8856 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8857 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8858 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8859 std::fs::write(
8860 path.join("bindings/uniffi-bindgen/Cargo.lock"),
8861 "# generated\n",
8862 )
8863 .unwrap();
8864
8865 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8866 .expect("a path the project ignores is the call's own output");
8867 }
8868
8869 #[test]
8870 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8871 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8872 exclude_paths(&repo, &["target/"]);
8873 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8874 std::fs::create_dir_all(path.join("target/package")).unwrap();
8875 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8876
8877 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8878 .expect("an ignored attribute file governs only ignored paths");
8879 assert!(
8880 !repo.has_uncommitted_changes(&path).unwrap(),
8881 "ignored dependency attributes must not stop the next review"
8882 );
8883 }
8884
8885 #[test]
8886 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
8887 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
8888 std::fs::create_dir_all(path.join("source")).unwrap();
8889 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
8890
8891 assert!(repo.has_uncommitted_changes(&path).unwrap());
8892 }
8893
8894 #[test]
8895 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
8896 for source in ["repository", "nested", "local", "global"] {
8897 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
8898 std::fs::create_dir_all(path.join("manager")).unwrap();
8899 match source {
8900 "repository" => {
8901 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
8902 .unwrap();
8903 test_git(&path, &["add", ".gitignore"]);
8904 test_git(&path, &["commit", "-m", "ignore artifacts"]);
8905 }
8906 "nested" => {
8907 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
8908 test_git(&path, &["add", "manager/.gitignore"]);
8909 test_git(&path, &["commit", "-m", "ignore artifacts"]);
8910 }
8911 "local" => exclude_paths(&repo, &["manager/scratch/"]),
8912 "global" => {
8913 let excludes = repo.root().parent().unwrap().join("global-ignore");
8914 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
8915 test_git(
8916 &path,
8917 &["config", "core.excludesFile", excludes.to_str().unwrap()],
8918 );
8919 }
8920 _ => unreachable!(),
8921 }
8922 let output = path.join("manager/scratch");
8923 std::fs::create_dir_all(&output).unwrap();
8924 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
8925 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
8926 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8927 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
8928 std::fs::remove_file(output.join(".DS_Store")).unwrap();
8929 for file in [
8930 ".gitattributes",
8931 "Cargo.lock",
8932 "font.woff2",
8933 "bundle.js",
8934 "cache.db",
8935 ] {
8936 std::fs::write(output.join(file), "generated\n").unwrap();
8937 }
8938 #[cfg(unix)]
8939 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
8940 let nested = output.join("dependency");
8941 std::fs::create_dir_all(&nested).unwrap();
8942 test_git(&nested, &["init"]);
8943 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8944 .unwrap_or_else(|error| panic!("{source}: {error}"));
8945 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
8946
8947 let baseline = repo.worktree_baseline(&path).unwrap();
8948 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
8949 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
8950 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
8951 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
8952 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
8953 assert!(repo
8954 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
8955 .unwrap_or_else(|error| panic!("{source}: {error}")));
8956 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
8957 assert!(test_git(
8958 &path,
8959 &[
8960 "ls-tree",
8961 "-r",
8962 "--name-only",
8963 "HEAD",
8964 "--",
8965 "manager/scratch"
8966 ]
8967 )
8968 .is_empty());
8969 assert!(
8970 repository_has_recoverable_work(&path, true).unwrap(),
8971 "{source}"
8972 );
8973 repo.release_review_worktree(945);
8974 assert_eq!(
8975 "LOCAL=updated\n",
8976 std::fs::read_to_string(output.join(".env.local")).unwrap()
8977 );
8978 assert!(nested.join(".git").exists(), "{source}");
8979 }
8980 }
8981
8982 #[test]
8986 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
8987 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
8988 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8989 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
8990
8991 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8992 .expect("spar's own marker is not something a call did");
8993 }
8994
8995 #[test]
8996 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
8997 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
8998
8999 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9000 .expect("a path a build removed is not an unreadable worktree");
9001
9002 assert_eq!(vanished_untracked_file(), gone);
9003 }
9004
9005 #[test]
9008 fn a_vanished_path_is_not_recorded_as_absent() {
9009 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9010 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9011 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9012 std::fs::remove_file(path.join("notes.txt")).unwrap();
9013
9014 let error = repo
9015 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9016 .unwrap_err();
9017
9018 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9019 }
9020
9021 fn exclude_paths(repo: &Repo, lines: &[&str]) {
9022 use std::io::Write;
9023 let path = repo.root().join(".git").join("info").join("exclude");
9024 let mut file = std::fs::OpenOptions::new()
9025 .create(true)
9026 .append(true)
9027 .open(&path)
9028 .unwrap();
9029 for line in lines {
9030 writeln!(file, "{line}").unwrap();
9031 }
9032 }
9033
9034 #[test]
9035 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9036 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9037 exclude_paths(&repo, &["dist/"]);
9038 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9039 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9040 let baseline = repo.worktree_baseline(&path).unwrap();
9041 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9042 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9043 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9044 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9045
9046 assert!(repo
9047 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9048 .unwrap());
9049 assert_eq!(
9050 "tracked change\n",
9051 std::fs::read_to_string(path.join("README.md")).unwrap()
9052 );
9053 }
9054
9055 #[test]
9056 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
9057 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
9058 exclude_paths(&repo, &["dist/"]);
9059 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9060 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9061 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
9062 let baseline = repo.worktree_baseline(&path).unwrap();
9063 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9064 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
9065 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9066 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9067
9068 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9069 repo.refuse_changed_existing_untracked(&path, &baseline)
9070 .unwrap();
9071 }
9072
9073 #[test]
9074 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
9075 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
9076 exclude_paths(&repo, &["dist/"]);
9077 std::fs::create_dir_all(path.join("dist")).unwrap();
9078 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9079 let baseline = repo.worktree_baseline(&path).unwrap();
9080 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9081 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9082
9083 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9084 repo.refuse_changed_existing_untracked(&path, &baseline)
9085 .unwrap();
9086 }
9087
9088 #[test]
9097 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
9098 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
9099 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
9100 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
9101 test_git(
9102 repo.root(),
9103 &[
9104 "worktree",
9105 "add",
9106 "-b",
9107 "issue-950",
9108 held.to_str().unwrap(),
9109 "main",
9110 ],
9111 );
9112
9113 let found = repo
9114 .worktree_holding("issue-950")
9115 .expect("the branch is checked out somewhere");
9116
9117 assert!(found.ends_with("issue-950"), "{}", found.display());
9118 assert_eq!(None, repo.worktree_holding("issue-951"));
9119 }
9120
9121 #[test]
9122 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
9123 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
9124 exclude_paths(&repo, &["manager/public/assets/"]);
9125 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
9126 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
9127 std::fs::write(&bundle, "first build\n").unwrap();
9128 let baseline = repo.worktree_baseline(&path).unwrap();
9129 std::fs::write(&bundle, "second build\n").unwrap();
9130 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
9131
9132 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
9133 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9134 }
9135
9136 #[test]
9137 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
9138 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
9139 exclude_paths(&repo, &["dist/"]);
9140 let baseline = repo.worktree_baseline(&path).unwrap();
9141 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
9142
9143 let error = repo
9144 .refuse_read_only_leavings(&path, &baseline)
9145 .unwrap_err();
9146
9147 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9148 assert!(error.to_string().contains("scratch.md"), "{error}");
9149 }
9150
9151 #[test]
9152 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
9153 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
9154 exclude_paths(&repo, &["dist/", ".env.local"]);
9155 std::fs::create_dir_all(path.join("dist")).unwrap();
9156 let baseline = repo.worktree_baseline(&path).unwrap();
9157 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
9158 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
9159
9160 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9161 assert!(repository_has_recoverable_work(&path, true).unwrap());
9162 }
9163
9164 #[test]
9165 fn a_read_only_inspection_may_rebuild_generated_output() {
9166 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
9167 exclude_paths(&repo, &["dist/"]);
9168 std::fs::create_dir_all(path.join("dist")).unwrap();
9169 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9170 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9171 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9172 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9173
9174 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9175 .unwrap();
9176 }
9177
9178 #[test]
9179 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
9180 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
9181 exclude_paths(&repo, &["dist/", ".env.local"]);
9182 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9183 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9184 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
9185
9186 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9187 .unwrap();
9188 }
9189
9190 #[test]
9191 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
9192 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
9193 exclude_paths(&repo, &["dist/", ".env.local"]);
9194 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9195 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9196 std::fs::remove_file(path.join(".env.local")).unwrap();
9197
9198 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9199 .unwrap();
9200 }
9201
9202 #[test]
9203 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
9204 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
9205 exclude_paths(&repo, &["dist/"]);
9206 std::fs::write(path.join("notes.md"), "before\n").unwrap();
9207 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9208 std::fs::write(path.join("notes.md"), "after\n").unwrap();
9209
9210 let error = repo
9211 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9212 .unwrap_err();
9213
9214 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9215 assert!(error.to_string().contains("notes.md"), "{error}");
9216 }
9217
9218 #[test]
9219 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
9220 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
9221 exclude_paths(&repo, &["dist/", "local.env"]);
9222 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
9223 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9224 std::fs::write(
9227 repo.root().join(".git").join("info").join("exclude"),
9228 "dist/\n",
9229 )
9230 .unwrap();
9231
9232 let error = repo
9233 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9234 .unwrap_err();
9235
9236 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9237 assert!(error.to_string().contains("local.env"), "{error}");
9238 }
9239
9240 #[test]
9241 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
9242 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
9243 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9244 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9245
9246 let error = repo
9247 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9248 .unwrap_err();
9249
9250 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9251 assert!(error.to_string().contains("README.md"), "{error}");
9252 }
9253
9254 #[test]
9255 fn build_output_alone_does_not_keep_a_worktree() {
9256 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
9257 exclude_paths(&repo, &["target/", "dist/"]);
9258 std::fs::create_dir_all(path.join("target/debug")).unwrap();
9259 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9260 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
9261 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
9262
9263 assert!(!repository_has_recoverable_work(&path, true).unwrap());
9264 repo.release_review_worktree(933);
9265
9266 assert!(!path.exists());
9267 }
9268
9269 #[test]
9270 fn finder_metadata_alone_does_not_keep_a_worktree() {
9271 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
9272 exclude_paths(&repo, &[".DS_Store"]);
9273 std::fs::create_dir_all(path.join("src")).unwrap();
9274 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
9275 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
9276
9277 assert!(!repository_has_recoverable_work(&path, true).unwrap());
9278 repo.release_review_worktree(937);
9279
9280 assert!(!path.exists());
9281 }
9282
9283 #[test]
9284 fn a_fetch_record_alone_does_not_keep_a_worktree() {
9285 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
9286 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9287 let git_dir = if raw.is_absolute() {
9288 raw
9289 } else {
9290 path.join(raw)
9291 };
9292 let head = test_git(&path, &["rev-parse", "HEAD"]);
9293 std::fs::write(
9294 git_dir.join("FETCH_HEAD"),
9295 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
9296 )
9297 .unwrap();
9298
9299 assert!(!repository_has_recoverable_work(&path, true).unwrap());
9300 repo.release_review_worktree(938);
9301
9302 assert!(!path.exists());
9303 }
9304
9305 #[test]
9306 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
9307 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
9308 exclude_paths(&repo, &["target/", ".env.local"]);
9309 std::fs::create_dir_all(path.join("target/debug")).unwrap();
9310 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9311 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
9312
9313 assert!(repository_has_recoverable_work(&path, true).unwrap());
9314 repo.release_review_worktree(934);
9315
9316 assert_eq!(
9317 "TOKEN=keep me\n",
9318 std::fs::read_to_string(path.join(".env.local")).unwrap()
9319 );
9320 }
9321
9322 #[test]
9323 fn a_repository_nested_in_build_output_keeps_a_worktree() {
9324 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
9325 exclude_paths(&repo, &["node_modules/"]);
9326 let nested = path.join("node_modules/local-dep");
9327 std::fs::create_dir_all(&nested).unwrap();
9328 test_git(&nested, &["init"]);
9329 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
9330
9331 assert!(repository_has_recoverable_work(&path, true).unwrap());
9332 repo.release_review_worktree(935);
9333
9334 assert!(nested.join(".git").exists());
9335 }
9336
9337 #[test]
9338 fn an_ordinary_untracked_file_keeps_a_worktree() {
9339 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
9340 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
9341
9342 assert!(repository_has_recoverable_work(&path, true).unwrap());
9343 repo.release_review_worktree(936);
9344
9345 assert_eq!(
9346 "somebody's notes\n",
9347 std::fs::read_to_string(path.join("notes.md")).unwrap()
9348 );
9349 }
9350
9351 #[test]
9352 fn a_legacy_crlf_checkout_is_retained_conservatively() {
9353 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
9354 std::fs::write(
9355 path.join(".gitattributes"),
9356 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
9357 )
9358 .unwrap();
9359 test_git(&path, &["add", ".gitattributes"]);
9360 test_git(&path, &["commit", "-m", "select legacy line endings"]);
9361
9362 assert!(repository_has_recoverable_work(&path, true).unwrap());
9363 }
9364
9365 #[test]
9366 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
9367 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
9368 let nested = path.join("tracked");
9369 std::fs::create_dir_all(&nested).unwrap();
9370 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
9371 test_git(&path, &["add", "tracked/seed.txt"]);
9372 test_git(&path, &["commit", "-m", "add tracked directory"]);
9373 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9374 test_git(&nested, &["init"]);
9375
9376 let error = repo
9377 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9378 .unwrap_err();
9379
9380 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9381 assert!(error.to_string().contains("Git entry"), "{error}");
9382 assert!(nested.join(".git").exists());
9383 }
9384
9385 #[test]
9390 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
9391 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
9392 exclude_paths(&repo, &["target/"]);
9393 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9394 let repro = path.join("target/pr918-repro");
9395 std::fs::create_dir_all(&repro).unwrap();
9396 test_git(&repro, &["init"]);
9397 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
9398
9399 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9400 .unwrap();
9401
9402 assert!(repository_has_recoverable_work(&path, true).unwrap());
9403 assert!(repro.join(".git").exists());
9404 }
9405
9406 #[test]
9410 fn a_repository_inside_a_nested_checkout_is_left_alone() {
9411 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
9412 exclude_paths(&repo, &["target/"]);
9413 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9414 let repro = path.join("target/repro");
9415 let vendored = repro.join("vendor/dep");
9416 std::fs::create_dir_all(&vendored).unwrap();
9417 test_git(&repro, &["init"]);
9418 test_git(&vendored, &["init"]);
9419
9420 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9421 .unwrap();
9422
9423 assert!(vendored.join(".git").exists());
9424 }
9425
9426 #[test]
9427 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
9428 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
9429
9430 let state = ignored_untracked_state(repo.root()).unwrap();
9431
9432 let relative = path.strip_prefix(repo.root()).unwrap();
9433 assert!(
9434 state.files.contains_key(relative),
9435 "{:?}",
9436 state.files.keys().collect::<Vec<_>>()
9437 );
9438 assert!(state.is_ignored(relative));
9439 }
9440
9441 #[test]
9442 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
9443 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
9444 let baseline = repo.worktree_baseline(repo.root()).unwrap();
9445 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
9446 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
9447
9448 repo.refuse_new_ignored_files(repo.root(), &baseline)
9449 .unwrap();
9450 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
9451 .unwrap();
9452 }
9453
9454 #[test]
9455 fn deleting_a_resident_worktree_during_a_call_is_refused() {
9456 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
9457 let baseline = repo.worktree_baseline(repo.root()).unwrap();
9458 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
9459 std::fs::remove_dir_all(&path).unwrap();
9460
9461 let read_error = repo
9462 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
9463 .unwrap_err();
9464 assert!(
9465 read_error.to_string().contains("review-932"),
9466 "{read_error}"
9467 );
9468 let error = repo
9469 .refuse_new_ignored_files(repo.root(), &baseline)
9470 .unwrap_err();
9471
9472 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9473 assert!(error.to_string().contains("review-932"), "{error}");
9474 }
9475
9476 #[test]
9477 fn a_nested_repository_record_is_read_as_a_plain_path() {
9478 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
9479 assert_eq!(Path::new("vendor/checkout"), path);
9480 assert!(nested);
9481
9482 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
9483 assert_eq!(Path::new("vendor/notes.txt"), path);
9484 assert!(!nested);
9485
9486 assert!(untracked_record(b"/", "untracked").is_err());
9487 }
9488
9489 #[cfg(unix)]
9490 #[test]
9491 fn a_non_utf8_git_path_is_preserved_without_loss() {
9492 use std::os::unix::ffi::OsStrExt;
9493
9494 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
9495
9496 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
9497 }
9498
9499 #[test]
9500 fn guarded_merge_pins_the_reviewed_head() {
9501 let args = merge_pr_args("36", Some("abc123"), true);
9502 assert_eq!(
9503 vec![
9504 "pr",
9505 "merge",
9506 "36",
9507 "--squash",
9508 "--delete-branch",
9509 "--match-head-commit",
9510 "abc123"
9511 ],
9512 args
9513 );
9514 }
9515
9516 #[test]
9517 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
9518 let pr = PrRef {
9519 number: 7,
9520 url: "https://example.test/pull/7".into(),
9521 title: "part one".into(),
9522 };
9523 let result = reconcile_pr_creation(
9524 "split-34-1",
9525 Err(crate::error::SparError::new("connection lost")),
9526 Ok(Some(pr)),
9527 )
9528 .unwrap();
9529 assert_eq!(7, result.number);
9530 }
9531
9532 #[test]
9533 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
9534 let error = reconcile_pr_creation(
9535 "split-34-1",
9536 Err(crate::error::SparError::new("permission denied")),
9537 Ok(None),
9538 )
9539 .unwrap_err();
9540 assert!(error.to_string().contains("permission denied"), "{error}");
9541 }
9542
9543 #[test]
9544 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
9545 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
9546 assert!(pr_for_base(text, "split-34-2", "split-34-1")
9547 .unwrap()
9548 .is_none());
9549 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
9550 assert_eq!(7, found.number);
9551 }
9552
9553 #[test]
9554 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
9555 let result = reconcile_comment_post(
9556 34,
9557 "the summary",
9558 crate::error::SparError::new("connection lost"),
9559 Ok(vec![serde_json::json!({"body": "the summary"})]),
9560 );
9561 assert!(result.is_ok(), "{result:?}");
9562 }
9563
9564 #[test]
9565 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
9566 let error = reconcile_comment_post(
9567 34,
9568 "the summary",
9569 crate::error::SparError::new("connection lost"),
9570 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
9571 )
9572 .unwrap_err();
9573 assert_eq!("connection lost", error.to_string());
9574 }
9575
9576 #[test]
9577 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
9578 let error = reconcile_comment_post(
9579 34,
9580 "the summary",
9581 crate::error::SparError::new("connection lost"),
9582 Err(crate::error::SparError::new("comments unavailable")),
9583 )
9584 .unwrap_err();
9585 assert!(
9586 error.to_string().contains("could not be verified"),
9587 "{error}"
9588 );
9589 assert!(
9590 error.to_string().contains("comments unavailable"),
9591 "{error}"
9592 );
9593 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9594 assert!(!error.worth_retrying());
9595 }
9596
9597 #[test]
9598 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
9599 let result = reconcile_issue_edit(
9600 34,
9601 "wanted body",
9602 crate::error::SparError::new("connection lost"),
9603 Ok("wanted body".to_string()),
9604 );
9605 assert!(result.is_ok(), "{result:?}");
9606 }
9607
9608 #[test]
9609 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
9610 let error = reconcile_issue_edit(
9611 34,
9612 "wanted body",
9613 crate::error::SparError::new("connection lost"),
9614 Err(crate::error::SparError::new("issue unavailable")),
9615 )
9616 .unwrap_err();
9617 assert!(
9618 error.to_string().contains("could not be verified"),
9619 "{error}"
9620 );
9621 assert!(error.to_string().contains("issue unavailable"), "{error}");
9622 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9623 assert!(!error.worth_retrying());
9624 }
9625
9626 #[test]
9627 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
9628 let found = ExistingIssue {
9629 number: 101,
9630 url: "https://example.test/issues/101".into(),
9631 title: "child".into(),
9632 body: "body".into(),
9633 open: true,
9634 };
9635 let url = reconcile_issue_creation(
9636 "child",
9637 Err(crate::error::SparError::new("connection lost")),
9638 Ok(Some(found)),
9639 )
9640 .unwrap();
9641 assert_eq!("https://example.test/issues/101", url);
9642 }
9643
9644 #[test]
9645 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
9646 let error = reconcile_issue_creation(
9647 "child",
9648 Err(crate::error::SparError::new("permission denied")),
9649 Ok(None),
9650 )
9651 .unwrap_err();
9652 assert!(error.to_string().contains("permission denied"), "{error}");
9653 }
9654
9655 #[test]
9656 fn an_unverifiable_issue_creation_is_marked_uncertain() {
9657 let error = reconcile_issue_creation(
9658 "child",
9659 Err(crate::error::SparError::new("connection lost")),
9660 Err(crate::error::SparError::new("issues unavailable")),
9661 )
9662 .unwrap_err();
9663 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9664 assert!(!error.worth_retrying());
9665 }
9666
9667 #[test]
9668 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
9669 let result = reconcile_failed_split_push(
9670 "split-34-1",
9671 crate::error::SparError::new("connection lost"),
9672 Ok("abc123\n".into()),
9673 Ok("abc123\trefs/heads/split-34-1\n".into()),
9674 );
9675 assert!(result.is_ok(), "{result:?}");
9676 }
9677
9678 #[test]
9679 fn a_split_push_collision_is_definite_and_never_overwrites() {
9680 let error = reconcile_failed_split_push(
9681 "split-34-1",
9682 crate::error::SparError::new("lease rejected"),
9683 Ok("abc123\n".into()),
9684 Ok("def456\trefs/heads/split-34-1\n".into()),
9685 )
9686 .unwrap_err();
9687 assert!(!error.retain_worktree());
9688 assert!(
9689 error.to_string().contains("Nothing was overwritten"),
9690 "{error}"
9691 );
9692 }
9693
9694 #[test]
9695 fn an_unreadable_split_push_result_keeps_the_worktree() {
9696 let error = reconcile_failed_split_push(
9697 "split-34-1",
9698 crate::error::SparError::new("connection lost"),
9699 Ok("abc123\n".into()),
9700 Err(crate::error::SparError::new("origin unavailable")),
9701 )
9702 .unwrap_err();
9703 assert!(error.retain_worktree());
9704 assert!(error.to_string().contains("could not confirm"), "{error}");
9705 }
9706
9707 #[test]
9711 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
9712 let repo = repo_for_titles();
9713 for raw in [
9714 "Retry loop spins \u{2014} Retry-After parses to zero",
9715 "plain title",
9716 " spread over\nlines ",
9717 "\u{1F916} Generated with something",
9718 &format!("a \u{2014} {}", "very long title ".repeat(20)),
9719 &"x".repeat(300),
9720 &format!("{} \u{2014} end", "y".repeat(88)),
9721 &{
9726 let tail = "a\u{2014}b c\u{2014}d";
9727 let pad = Style::default().max_title_chars - tail.chars().count();
9728 format!("{}{tail}", "w".repeat(pad))
9729 },
9730 ] {
9731 let once = repo.clean_title(raw).unwrap();
9732 let twice = repo.clean_title(&once).unwrap();
9733 assert_eq!(once, twice, "not idempotent for {raw:?}");
9734 assert!(
9735 once.chars().count() <= repo.style.max_title_chars,
9736 "over budget: {once:?}"
9737 );
9738 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
9739 }
9740 }
9741
9742 #[test]
9743 fn a_title_with_an_em_dash_survives_as_readable_text() {
9744 let repo = repo_for_titles();
9745 assert_eq!(
9746 "Retry loop spins, Retry-After parses to zero",
9747 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
9748 .unwrap()
9749 );
9750 }
9751
9752 #[test]
9753 fn sh_quote_survives_a_quote() {
9754 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
9755 }
9756
9757 #[test]
9758 fn sh_quote_wraps_a_space() {
9759 assert_eq!(
9760 "'/Applications/My App/spar'",
9761 sh_quote("/Applications/My App/spar")
9762 );
9763 }
9764
9765 #[test]
9766 fn finished_states_are_recognised_case_insensitively() {
9767 assert!(is_finished("MERGED"));
9768 assert!(is_finished("closed"));
9769 assert!(!is_finished("OPEN"));
9770 assert!(!is_finished(""));
9771 }
9772
9773 fn state() -> PersistedState {
9774 PersistedState {
9775 version: 1,
9776 checkpoint: 0,
9777 round: 4,
9778 next_actor: "codex".into(),
9779 status: Status::Pending,
9780 pr_head: "abc123".into(),
9781 ledger: Ledger::new(),
9782 filed: vec![],
9783 open_findings: vec![Finding {
9784 severity: Severity::Blocking,
9785 title: "Unchecked error".into(),
9786 detail: "the failure is discarded".into(),
9787 file: "src/a.rs:12".into(),
9788 ..Finding::default()
9789 }],
9790 disputes: vec![Dispute {
9791 title: "Retry limit".into(),
9792 file: "src/net.rs".into(),
9793 reasoning: "the caller already bounds it".into(),
9794 }],
9795 noted: vec![Finding {
9796 severity: Severity::NonBlocking,
9797 title: "Timeout is fixed".into(),
9798 file: "src/config.rs".into(),
9799 ..Finding::default()
9800 }],
9801 }
9802 }
9803
9804 #[test]
9805 fn a_state_comment_round_trips() {
9806 let body = format!(
9807 "{STATE_MARKER}\n{}\n-->",
9808 serde_json::to_string(&state()).unwrap()
9809 );
9810 let back = parse_state_comment(&body).unwrap();
9811 assert_eq!(4, back.round);
9812 assert_eq!("codex", back.next_actor);
9813 assert_eq!("abc123", back.pr_head);
9814 assert_eq!("Unchecked error", back.open_findings[0].title);
9815 assert_eq!("src/net.rs", back.disputes[0].file);
9816 assert_eq!("Timeout is fixed", back.noted[0].title);
9817 }
9818
9819 #[test]
9820 fn old_state_without_new_lists_still_parses() {
9821 let body = format!(
9822 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
9823 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9824 );
9825 let back = parse_state_comment(&body).expect("old state");
9826 assert!(back.open_findings.is_empty());
9827 assert!(back.disputes.is_empty());
9828 assert!(back.noted.is_empty());
9829 assert!(back.pr_head.is_empty());
9830 assert_eq!(0, back.checkpoint);
9831 }
9832
9833 #[test]
9834 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
9835 let mut local = state();
9836 local.pr_head = "old".into();
9837 local.round = 9;
9838 let mut remote = state();
9839 remote.pr_head = "current".into();
9840 remote.round = 4;
9841
9842 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9843 assert_eq!("current", chosen.pr_head);
9844 assert_eq!(4, chosen.round);
9845 }
9846
9847 #[test]
9848 fn checkpoint_order_breaks_same_round_ties() {
9849 let mut local = state();
9850 local.pr_head = "current".into();
9851 local.round = 4;
9852 local.checkpoint = 8;
9853 let mut remote = local.clone();
9854 remote.checkpoint = 7;
9855 remote.open_findings.clear();
9856
9857 let chosen = choose_state_for_head(vec![local], "current").unwrap();
9858 assert_eq!(8, chosen.checkpoint);
9859
9860 let mut local = state();
9861 local.pr_head = "current".into();
9862 local.round = 4;
9863 local.checkpoint = 8;
9864 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
9865 assert_eq!(8, chosen.checkpoint);
9866 }
9867
9868 #[test]
9869 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
9870 let mut local = state();
9871 local.pr_head = "current".into();
9872 local.round = 4;
9873 local.open_findings.push(Finding {
9874 title: "local checkpoint".into(),
9875 ..Finding::default()
9876 });
9877 let mut remote = state();
9878 remote.pr_head = "current".into();
9879 remote.round = 4;
9880
9881 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9882 assert_eq!(
9883 "local checkpoint",
9884 chosen.open_findings.last().unwrap().title
9885 );
9886 }
9887
9888 #[test]
9890 fn the_state_block_is_an_html_comment() {
9891 let body = format!(
9892 "{STATE_MARKER}\n{}\n-->",
9893 serde_json::to_string(&state()).unwrap()
9894 );
9895 assert!(body.starts_with("<!--"));
9896 assert!(body.trim_end().ends_with("-->"));
9897 assert!(!body[..body.find('{').unwrap()].contains("-->"));
9898 }
9899
9900 #[test]
9901 fn an_unrelated_json_block_is_not_state() {
9902 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
9903 }
9904
9905 #[test]
9912 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
9913 let block = format!(
9914 "{STATE_MARKER}\n{}\n-->",
9915 serde_json::to_string(&state()).unwrap()
9916 );
9917 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
9918 assert!(
9919 is_state_comment(&block, "Spar-Bot", "spar-bot"),
9920 "a login differs only in case"
9921 );
9922
9923 assert!(
9924 !is_state_comment(
9925 "why is there a `<!-- spar:state` block on this PR?",
9926 "maintainer",
9927 "spar-bot"
9928 ),
9929 "a question about the block was treated as the block"
9930 );
9931 assert!(
9932 !is_state_comment(&block, "maintainer", "spar-bot"),
9933 "somebody else's comment is not spar's to rewrite"
9934 );
9935 assert!(
9936 !is_state_comment(
9937 &format!("here is what spar writes:\n\n{block}"),
9938 "spar-bot",
9939 "spar-bot"
9940 ),
9941 "the marker has to open the body, not appear in it"
9942 );
9943 assert!(
9944 !is_state_comment(
9945 &format!("{STATE_MARKER}\n{{not json\n-->"),
9946 "spar-bot",
9947 "spar-bot"
9948 ),
9949 "a block that does not parse is not state"
9950 );
9951 assert!(
9952 !is_state_comment(&block, "spar-bot", ""),
9953 "an unknown viewer proves nothing about who wrote it"
9954 );
9955 }
9956
9957 #[test]
9958 fn a_malformed_state_comment_is_none_not_a_panic() {
9959 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
9960 }
9961
9962 #[test]
9963 fn atomic_write_leaves_no_temp_file() {
9964 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
9965 let _ = std::fs::remove_dir_all(&dir);
9966 let path = dir.join("state").join("pr-7.json");
9967 write_json_atomic(&path, &state()).unwrap();
9968 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
9969 .unwrap()
9970 .flatten()
9971 .filter_map(|e| e.file_name().to_str().map(str::to_string))
9972 .collect();
9973 assert_eq!(vec!["pr-7.json".to_string()], files);
9974 let _ = std::fs::remove_dir_all(&dir);
9975 }
9976
9977 #[test]
9978 fn atomic_write_overwrites_rather_than_accumulating() {
9979 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
9980 let _ = std::fs::remove_dir_all(&dir);
9981 let path = dir.join("pr-7.json");
9982 for round in 1..4 {
9983 let mut s = state();
9984 s.round = round;
9985 write_json_atomic(&path, &s).unwrap();
9986 }
9987 let back: PersistedState =
9988 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9989 assert_eq!(3, back.round);
9990 let _ = std::fs::remove_dir_all(&dir);
9991 }
9992
9993 #[test]
9994 fn style_from_env_defaults_to_enforcing() {
9995 std::env::remove_var("SPAR_BAN_EM_DASH");
9996 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
9997 let style = style_from_env();
9998 assert!(style.ban_em_dash && style.ban_ai_attribution);
9999 assert!(
10000 !style.terse,
10001 "the commit filter must not truncate a commit message"
10002 );
10003 }
10004}
10005
10006#[cfg(test)]
10007mod comment_page_tests {
10008 use super::*;
10009
10010 #[test]
10011 fn a_single_merged_array_is_read() {
10012 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
10013 assert_eq!(2, pages.len());
10014 assert_eq!(Some(2), pages[1]["id"].as_i64());
10015 }
10016
10017 #[test]
10018 fn concatenated_pages_from_an_older_gh_are_read_too() {
10019 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
10020 assert_eq!(2, pages.len());
10021 }
10022
10023 #[test]
10027 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
10028 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
10029 let pages = parse_comment_pages(text);
10030 assert_eq!(2, pages.len(), "{pages:?}");
10031 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
10032 }
10033
10034 #[test]
10035 fn empty_output_is_no_comments_not_a_panic() {
10036 assert!(parse_comment_pages("").is_empty());
10037 assert!(parse_comment_pages(" ").is_empty());
10038 assert!(parse_comment_pages("[]").is_empty());
10039 }
10040
10041 #[test]
10042 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
10043 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
10044 }
10045
10046 #[test]
10047 fn a_write_postcheck_rejects_truncated_comment_pages() {
10048 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
10049 assert!(
10050 error.to_string().contains("unexpected comment pages"),
10051 "{error}"
10052 );
10053 }
10054
10055 #[test]
10056 fn a_write_postcheck_rejects_empty_or_non_array_output() {
10057 assert!(try_parse_comment_pages("").is_err());
10058 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
10059 assert!(try_parse_comment_pages("[]").is_ok());
10060 }
10061
10062 #[test]
10063 fn state_is_found_in_the_last_matching_comment() {
10064 let payload = |round: u32| {
10065 format!(
10066 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10067 )
10068 };
10069 let text = serde_json::to_string(&serde_json::json!([
10070 {"id": 1, "body": payload(1)},
10071 {"id": 2, "body": "looks good to me"},
10072 {"id": 3, "body": payload(5)},
10073 ]))
10074 .unwrap();
10075 let pages = parse_comment_pages(&text);
10076 let last = pages
10077 .iter()
10078 .rev()
10079 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
10080 .unwrap();
10081 assert_eq!(5, last.round);
10082 }
10083}
10084
10085#[cfg(test)]
10086mod linked_pr_tests {
10087 use super::*;
10088
10089 const REAL_PAYLOAD: &str = r#"[
10094 {"number":14252,"title":"fix: reject leading-dash branch names",
10095 "url":"https://github.com/cli/cli/pull/14252",
10096 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
10097 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
10098 "url":"https://github.com/cli/cli/issues/14238"}]},
10099 {"number":14217,"title":"another change",
10100 "url":"https://github.com/cli/cli/pull/14217",
10101 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
10102 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
10103 "url":"https://github.com/cli/cli/issues/9761"}]},
10104 {"number":14200,"title":"unlinked work",
10105 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
10106 ]"#;
10107
10108 #[test]
10109 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
10110 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
10111 assert_eq!(14252, pr.number);
10112 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
10113 }
10114
10115 #[test]
10116 fn the_right_pr_is_picked_out_of_several() {
10117 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
10118 }
10119
10120 #[test]
10121 fn an_issue_nobody_is_working_on_finds_nothing() {
10122 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
10123 }
10124
10125 #[test]
10126 fn an_unlinked_pr_is_never_matched() {
10127 for issue in [14200, 0, 1] {
10129 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
10130 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
10131 }
10132 }
10133 }
10134
10135 #[test]
10136 fn empty_or_broken_output_is_none_rather_than_a_panic() {
10137 assert!(find_linked_pr("", 1).is_none());
10138 assert!(find_linked_pr("[]", 1).is_none());
10139 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
10140 assert!(find_linked_pr("[{\"number\":", 1).is_none());
10141 }
10142
10143 #[test]
10150 fn an_unreadable_list_is_not_an_empty_list() {
10151 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
10152 assert!(linked_pr_rows("[{\"number\":").is_none());
10153 assert!(linked_pr_rows("").is_none());
10154
10155 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
10156 assert!(empty.is_empty());
10157 assert!(pick_linked_pr(&empty, 14238).is_none());
10158
10159 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
10160 assert_eq!(3, listed.len());
10161 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
10162 }
10163
10164 #[test]
10166 fn pr_view_reads_the_cross_repository_flag() {
10167 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
10168 "baseRefName":"main","state":"OPEN",
10169 "closingIssuesReferences":[],"isCrossRepository":true}"#;
10170 let pr: PrView = serde_json::from_str(json).unwrap();
10171 assert!(pr.is_cross_repository);
10172 assert!(pr.is_open());
10173
10174 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
10175 assert!(
10176 !serde_json::from_str::<PrView>(&same_repo)
10177 .unwrap()
10178 .is_cross_repository
10179 );
10180 }
10181}
10182
10183#[cfg(test)]
10184mod min_number_tests {
10185 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
10191 let mut numbers: Vec<i64> = open.to_vec();
10192 numbers.sort_unstable();
10193 if min_number > 0 {
10194 numbers.retain(|n| *n >= min_number);
10195 }
10196 numbers.truncate(limit);
10197 numbers
10198 }
10199
10200 #[test]
10201 fn the_floor_is_applied_before_the_cap_not_after() {
10202 let open = [12, 13, 14, 480, 481, 482];
10203 assert_eq!(vec![480, 481], pick(&open, 2, 480));
10204 assert!(!pick(&open, 2, 480).is_empty());
10207 }
10208
10209 #[test]
10210 fn no_floor_keeps_the_old_behaviour() {
10211 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
10212 }
10213
10214 #[test]
10215 fn the_floor_is_inclusive() {
10216 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
10217 }
10218
10219 #[test]
10220 fn a_floor_above_everything_open_yields_nothing() {
10221 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
10222 }
10223}
10224
10225#[cfg(test)]
10226mod review_record_tests {
10227 use super::*;
10228 use serde_json::json;
10229
10230 #[test]
10231 fn prune_state_knows_which_files_belong_to_a_pull_request() {
10232 assert_eq!(Some(12), state_file_number("pr-12.json"));
10233 assert_eq!(Some(12), state_file_number("review-12.json"));
10234 assert_eq!(Some(12), state_file_number("checkin-12.json"));
10235 for shared in [
10236 "spend.json",
10237 "implementors.json",
10238 "contested.json",
10239 "listen.json",
10240 "pr-.json",
10241 "pr-12.json.tmp",
10242 "pr-x.json",
10243 ] {
10244 assert_eq!(None, state_file_number(shared), "{shared}");
10245 }
10246 }
10247
10248 #[test]
10249 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
10250 let reviews = vec![
10251 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
10252 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
10253 ];
10254 assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
10255 assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
10256 assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
10257 }
10258
10259 #[test]
10263 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
10264 let older = vec![
10265 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
10266 ];
10267 assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
10268 let dismissed = vec![
10269 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
10270 ];
10271 assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
10272 }
10273
10274 #[test]
10275 fn a_response_splits_into_status_headers_and_body() {
10276 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";
10277 let resp = parse_api_response(text).expect("a response");
10278 assert_eq!(200, resp.status);
10279 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
10280 assert_eq!(
10281 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
10282 resp.headers.get("last-modified")
10283 );
10284 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
10285
10286 let not_modified =
10287 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
10288 assert_eq!(304, not_modified.status);
10289 assert!(not_modified.body.is_empty());
10290
10291 assert_eq!(None, parse_api_response(""));
10292 assert_eq!(None, parse_api_response("gh: command not found\n"));
10293 }
10294
10295 #[test]
10296 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
10297 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
10298 let error = crate::error::SparError::call_failed("gh: boom");
10299 assert!(
10300 reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
10301 );
10302
10303 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
10304 let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
10305 .unwrap_err();
10306 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
10307
10308 let unreadable = Err(crate::error::SparError::new("no network"));
10309 let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
10310 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
10311 assert!(err.to_string().contains("could not be verified"), "{err}");
10312 }
10313}