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