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