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::{CheckCommand, Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20 Contested, ContestedItem, Followup, InlineComment, Issue, IssueRef, ItemKind, LiveHead,
21 PersistedState, PrRef, PrRow, PrSweepRow, PrView, PR_SWEEP_FIELDS,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, log, 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";
66const READ_CHECKOUT_PREFIX: &str = "read-";
69
70pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
72pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub(crate) enum Disposable {
77 BuildOutput,
80 ReviewOutput,
92}
93
94pub(crate) fn is_spar_marker(path: &Path) -> bool {
102 let mut components = path.components();
103 let Some(std::path::Component::Normal(name)) = components.next() else {
104 return false;
105 };
106 if components.next().is_some() {
107 return false;
108 }
109 let Some(name) = name.to_str() else {
110 return false;
111 };
112 name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
113}
114
115pub(crate) fn is_spar_own_path(path: &Path) -> bool {
128 if is_spar_marker(path) {
129 return true;
130 }
131 let Some(std::path::Component::Normal(name)) = path.components().next() else {
132 return false;
133 };
134 name == OsStr::new(STATE_DIR)
135}
136
137const SPLIT_SLOTS: u32 = 20;
142
143const SETUP_TIMEOUT_SECS: u64 = 1800;
146
147#[derive(Debug, Clone)]
148pub struct SplitPushError {
149 message: String,
150 retain_worktree: bool,
151}
152
153impl SplitPushError {
154 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
155 Self {
156 message: message.into(),
157 retain_worktree,
158 }
159 }
160
161 pub fn retain_worktree(&self) -> bool {
162 self.retain_worktree
163 }
164}
165
166impl std::fmt::Display for SplitPushError {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.write_str(&self.message)
169 }
170}
171
172impl std::error::Error for SplitPushError {}
173
174fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
179 match attempt {
180 1 => format!("split-{parent}-{index}"),
181 n => format!("split-{parent}-{index}-{n}"),
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum PushTarget {
188 Origin,
189 Fork(Fork),
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Fork {
197 pub owner: String,
200 pub full_name: String,
202 pub url: String,
205}
206
207impl PushTarget {
208 pub fn head(&self, branch: &str) -> String {
210 match self {
211 PushTarget::Origin => branch.to_string(),
212 PushTarget::Fork(fork) => format!("{}:{branch}", fork.owner),
213 }
214 }
215
216 fn remote(&self) -> &str {
217 match self {
218 PushTarget::Origin => "origin",
219 PushTarget::Fork(fork) => &fork.url,
220 }
221 }
222
223 fn describe(&self, branch: &str) -> String {
224 match self {
225 PushTarget::Origin => format!("origin/{branch}"),
226 PushTarget::Fork(fork) => format!("{branch} on the fork {}", fork.full_name),
227 }
228 }
229}
230
231pub struct ReadCheckout<'a> {
244 repo: &'a Repo,
245 path: PathBuf,
246 checkpoint: Option<WorktreeCheckpoint>,
249}
250
251impl ReadCheckout<'_> {
252 pub fn path(&self) -> &Path {
253 &self.path
254 }
255}
256
257impl Drop for ReadCheckout<'_> {
258 fn drop(&mut self) {
259 if let Some(checkpoint) = self.checkpoint.take() {
260 self.repo.release_read_checkout(&self.path, &checkpoint);
261 }
262 }
263}
264
265#[derive(Debug)]
266pub struct Repo {
267 root: PathBuf,
268 pub style: Style,
269 pub branch_prefix: String,
270 pub state_store: StateStore,
271 pub followups: Followups,
272 pub drafts: Drafts,
273 pub fork_when_denied: bool,
275 setup: CheckCommand,
277 isolate_reads: bool,
280 keep_worktrees: bool,
282 fork: OnceLock<Fork>,
285 viewer: OnceLock<String>,
291 checkpoints: Mutex<BTreeMap<i64, u64>>,
296 writes: WriteStats,
297}
298
299#[derive(Debug, Default)]
300struct WriteStats {
301 attempted: AtomicUsize,
302 failed: AtomicUsize,
303}
304
305#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
306pub(crate) struct WriteSummary {
307 pub(crate) attempted: usize,
308 pub(crate) failed: usize,
309}
310
311impl WriteSummary {
312 pub(crate) fn succeeded(self) -> usize {
313 self.attempted.saturating_sub(self.failed)
314 }
315}
316
317#[derive(Debug, Clone)]
323pub(crate) struct WorktreeBaseline {
324 attributes: AttributeState,
325 ignored_untracked: IgnoredState,
326 git_state: GitState,
327}
328
329#[derive(Debug, Clone)]
335pub(crate) struct WorktreeCheckpoint {
336 path: PathBuf,
337 attributes: AttributeState,
338 git_state: GitState,
339 ignored_untracked: IgnoredState,
340}
341
342#[derive(Debug, Clone, Default, PartialEq, Eq)]
343pub(crate) struct AttributeState {
344 files: BTreeMap<PathBuf, [u8; 32]>,
345}
346
347#[derive(Debug, Clone, Default, PartialEq, Eq)]
352pub(crate) struct IgnoredState {
353 files: BTreeMap<PathBuf, UntrackedFile>,
354 ignored: BTreeSet<PathBuf>,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
364struct UntrackedFile {
365 kind: u8,
366 len: u64,
367 modified: Option<std::time::SystemTime>,
368 created: Option<std::time::SystemTime>,
369 readonly: bool,
370 symlink_target: Option<Vec<u8>>,
371 #[cfg(unix)]
372 device: u64,
373 #[cfg(unix)]
374 inode: u64,
375 #[cfg(unix)]
376 mode: u32,
377 #[cfg(unix)]
378 change_seconds: i64,
379 #[cfg(unix)]
380 change_nanoseconds: i64,
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Eq)]
384pub(crate) struct GitState {
385 repositories: BTreeMap<PathBuf, RepositoryState>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
389struct RepositoryState {
390 head: String,
391 unsafe_index_flags: Vec<u8>,
392 tracked: BTreeMap<PathBuf, TrackedEntry>,
393 gitlinks: BTreeMap<PathBuf, String>,
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
397struct TrackedEntry {
398 index_mode: String,
399 index_oid: String,
400 worktree: Option<WorktreeFile>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
404struct WorktreeFile {
405 mode: String,
406 #[cfg(unix)]
407 permissions: u32,
408 raw_oid: String,
409 fingerprint: [u8; 32],
410 content: [u8; 32],
411}
412
413struct Gitlink {
414 path: PathBuf,
415 oid: String,
416}
417
418struct IndexEntry {
419 path: PathBuf,
420 mode: String,
421 oid: String,
422}
423
424impl IgnoredState {
425 fn is_ignored(&self, path: &Path) -> bool {
426 self.ignored.contains(path)
427 }
428
429 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
430 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
431 paths.extend(after.files.keys().cloned());
432 paths
433 .into_iter()
434 .filter(|path| {
435 self.files.get(path) != after.files.get(path)
436 || self.is_ignored(path) != after.is_ignored(path)
437 })
438 .collect()
439 }
440
441 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
442 self.files
443 .iter()
444 .filter(|(path, state)| after.files.get(*path) != Some(*state))
445 .map(|(path, _)| path.clone())
446 .collect()
447 }
448
449 pub(crate) fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
450 after
451 .files
452 .keys()
453 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
454 .cloned()
455 .collect()
456 }
457
458 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
468 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
469 paths.extend(after.files.keys());
470 paths.into_iter().any(|path| {
471 if self.files.get(path) == after.files.get(path)
472 && self.is_ignored(path) == after.is_ignored(path)
473 {
474 return false;
475 }
476 !self.generated_move(after, path)
477 })
478 }
479
480 pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
508 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
509 paths.extend(after.files.keys());
510 let mut leavings = ReadOnlyLeavings::default();
511 for path in paths {
512 if self.files.get(path) == after.files.get(path)
513 && self.is_ignored(path) == after.is_ignored(path)
514 {
515 continue;
516 }
517 if !self.ignored_file_move(after, path) {
518 leavings.condemning.push(path.clone());
519 } else if !is_generated_artifact(path) {
520 leavings.tolerated.push(path.clone());
521 }
522 }
523 leavings
524 }
525
526 fn generated_move(&self, after: &Self, path: &Path) -> bool {
540 is_generated_artifact(path) && self.disposable_move(after, path)
541 }
542
543 fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
547 if path.starts_with(WORKTREE_DIR)
548 && self.files.get(path).is_some_and(|file| file.kind == 3)
549 && self.files.get(path) != after.files.get(path)
550 {
551 return false;
552 }
553 self.disposable_move(after, path)
554 }
555
556 fn disposable_move(&self, after: &Self, path: &Path) -> bool {
559 self.disposable(path) && after.disposable(path)
560 }
561
562 fn disposable(&self, path: &Path) -> bool {
565 !self.files.contains_key(path) || self.is_ignored(path)
566 }
567}
568
569impl GitState {
570 pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
576 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
577 prefixes.extend(after.repositories.keys());
578 let mut notes: Vec<String> = Vec::new();
579 let mut paths: Vec<PathBuf> = Vec::new();
580 for prefix in prefixes {
581 let label = describe_repository(prefix);
582 match (
583 self.repositories.get(prefix),
584 after.repositories.get(prefix),
585 ) {
586 (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
587 (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
588 (Some(before), Some(current)) => {
589 if before.head != current.head {
590 notes.push(format!("HEAD moved in {label}"));
591 }
592 if before.unsafe_index_flags != current.unsafe_index_flags {
593 notes.push(format!("index flags changed in {label}"));
594 }
595 paths.extend(
596 differing_keys(&before.gitlinks, ¤t.gitlinks)
597 .chain(differing_keys(&before.tracked, ¤t.tracked))
598 .map(|path| prefix.join(path)),
599 );
600 }
601 (None, None) => {}
602 }
603 }
604 if !paths.is_empty() {
605 paths.sort();
606 paths.dedup();
607 notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
608 }
609 (!notes.is_empty()).then(|| notes.join("; "))
610 }
611
612 pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
615 let difference = self.describe_difference(after)?;
616 let Some(moved) = self.moved_wholesale(after) else {
617 return Some(difference);
618 };
619 if said_once("moved", cwd, &moved) {
622 logwarn!(
623 "the checkout at {} moved while a read-only call was reading it: {moved}. The \
624 answer was kept, and it describes the tree as it stood before the move.",
625 cwd.display()
626 );
627 }
628 None
629 }
630
631 fn moved_wholesale(&self, after: &Self) -> Option<String> {
648 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
649 prefixes.extend(after.repositories.keys());
650 let mut notes: Vec<String> = Vec::new();
651 for prefix in prefixes {
652 let before = self.repositories.get(prefix)?;
654 let current = after.repositories.get(prefix)?;
655 if before.unsafe_index_flags != current.unsafe_index_flags
656 || before.gitlinks != current.gitlinks
657 {
658 return None;
659 }
660 let moved: Vec<&PathBuf> = differing_keys(&before.tracked, ¤t.tracked).collect();
661 if before.head == current.head {
662 if moved.is_empty() {
663 continue;
664 }
665 return None;
666 }
667 if !moved.iter().all(|path| {
668 checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
669 }) {
670 return None;
671 }
672 let label = describe_repository(prefix);
673 notes.push(match moved.len() {
674 0 => format!("HEAD moved in {label}"),
675 count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
676 });
677 }
678 (!notes.is_empty()).then(|| notes.join("; "))
679 }
680}
681
682fn checked_out(entry: Option<&TrackedEntry>) -> bool {
689 entry.is_none_or(|entry| {
690 entry
691 .worktree
692 .as_ref()
693 .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
694 })
695}
696
697pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
708 if paths.is_empty() {
709 return;
710 }
711 let named = list_paths(paths);
712 if !said_once("ignored", cwd, &named) {
713 return;
714 }
715 logdim!(
716 "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
717 project ignores these paths.",
718 paths.len(),
719 cwd.display()
720 );
721}
722
723fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
730 static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
731 SAID.lock()
732 .unwrap_or_else(std::sync::PoisonError::into_inner)
733 .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
734}
735
736fn describe_repository(prefix: &Path) -> String {
739 if prefix.as_os_str().is_empty() {
740 "the worktree".to_string()
741 } else {
742 format!("the submodule at {:?}", prefix.as_os_str())
743 }
744}
745
746fn differing_keys<'a, V: PartialEq>(
748 before: &'a BTreeMap<PathBuf, V>,
749 after: &'a BTreeMap<PathBuf, V>,
750) -> impl Iterator<Item = &'a PathBuf> {
751 let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
752 keys.extend(after.keys());
753 keys.into_iter()
754 .filter(move |key| before.get(*key) != after.get(*key))
755}
756
757#[derive(Default)]
763pub(crate) struct ReadOnlyLeavings {
764 pub(crate) tolerated: Vec<PathBuf>,
768 pub(crate) condemning: Vec<PathBuf>,
770}
771
772pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
776 match (first, second) {
777 (None, None) => None,
778 (Some(one), None) | (None, Some(one)) => Some(one),
779 (Some(first), Some(second)) => Some(format!("{first}; {second}")),
780 }
781}
782
783pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
788 const SHOWN: usize = 5;
789 let mut listed = paths
790 .iter()
791 .take(SHOWN)
792 .map(|path| format!("{:?}", path.as_os_str()))
793 .collect::<Vec<_>>()
794 .join(", ");
795 if paths.len() > SHOWN {
796 listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
797 }
798 listed
799}
800
801#[derive(Default)]
808struct IgnoredArtifacts {
809 new_paths: BTreeSet<PathBuf>,
810 changed_paths: BTreeSet<PathBuf>,
811 removed_paths: BTreeSet<PathBuf>,
812}
813
814impl IgnoredArtifacts {
815 fn left(&mut self, allowed: AllowedArtifacts) {
816 self.new_paths.extend(allowed.present);
817 self.removed_paths.extend(allowed.removed);
818 }
819
820 fn changed(&mut self, allowed: AllowedArtifacts) {
821 self.changed_paths.extend(allowed.present);
822 self.removed_paths.extend(allowed.removed);
823 }
824
825 fn report(&self, cwd: &Path) {
827 let paths: BTreeSet<_> = self
828 .new_paths
829 .iter()
830 .chain(&self.changed_paths)
831 .chain(&self.removed_paths)
832 .cloned()
833 .collect();
834 if !paths.is_empty() {
835 logdim!(
836 "the editing call changed {} ignored file(s) in {}: {}. They are outside \
837 the commit; cleanup checks them separately.",
838 paths.len(),
839 cwd.display(),
840 list_paths(&paths.into_iter().collect::<Vec<_>>())
841 );
842 }
843 }
844}
845
846#[derive(Default)]
850struct AllowedArtifacts {
851 present: Vec<PathBuf>,
852 removed: Vec<PathBuf>,
853}
854
855impl AllowedArtifacts {
856 fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
857 let (present, removed) = paths
858 .into_iter()
859 .partition(|path| after.files.contains_key(path));
860 Self { present, removed }
861 }
862}
863
864fn is_generated_artifact(path: &Path) -> bool {
870 const DIRECTORIES: &[&str] = &[
871 "target",
872 "dist",
873 "node_modules",
874 "__pycache__",
875 ".pytest_cache",
876 ".mypy_cache",
877 ".ruff_cache",
878 ".tox",
879 ".nox",
880 ".venv",
881 "venv",
882 ".gradle",
883 ".build",
884 "DerivedData",
885 ".next",
886 ".nuxt",
887 ".svelte-kit",
888 ".turbo",
889 "coverage",
890 ];
891 path.components().any(|component| {
892 let std::path::Component::Normal(name) = component else {
893 return false;
894 };
895 DIRECTORIES
896 .iter()
897 .any(|directory| name == OsStr::new(directory))
898 })
899}
900
901fn is_desktop_metadata(path: &Path) -> bool {
907 path.file_name() == Some(OsStr::new(".DS_Store"))
908}
909
910fn merge_pr_args<'a>(
911 number: &'a str,
912 expected_head: Option<&'a str>,
913 delete_branch: bool,
914) -> Vec<&'a str> {
915 let mut args = vec!["pr", "merge", number, "--squash"];
916 if delete_branch {
917 args.push("--delete-branch");
918 }
919 if let Some(expected_head) = expected_head {
920 args.extend(["--match-head-commit", expected_head]);
921 }
922 args
923}
924
925fn push_denial(message: &str) -> Option<&str> {
935 message
936 .lines()
937 .map(|line| {
938 let line = line.trim();
939 let line = line.strip_prefix("ERROR:").unwrap_or(line);
940 line.strip_prefix("remote:").unwrap_or(line).trim()
941 })
942 .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
943}
944
945fn push_rejection(message: &str) -> Option<&str> {
948 message
949 .lines()
950 .map(|line| line.trim().trim_start_matches('!').trim())
951 .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
952}
953
954fn push_error(
955 target: &PushTarget,
956 branch: &str,
957 e: &crate::error::SparError,
958) -> crate::error::SparError {
959 let message = e.to_string();
960 if let Some(denied) = push_denial(&message) {
961 return spar_err!("could not push to {}. {denied}", target.describe(branch));
962 }
963 spar_err!(
964 "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
965 target.describe(branch),
966 push_rejection(&message).unwrap_or(e.last_line())
967 )
968}
969
970fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
979 let origin = origin_url.trim();
980 let path_start = match origin.find("://") {
981 Some(scheme) => {
982 let host = scheme + 3;
983 host + origin[host..].find('/')? + 1
984 }
985 None => {
986 let colon = origin.find(':')?;
989 if origin[..colon].contains('/') {
990 return None;
991 }
992 colon + 1
993 }
994 };
995 let path = origin[path_start..].trim_end_matches('/');
996 let (path, suffix) = match path.strip_suffix(".git") {
997 Some(bare) => (bare, ".git"),
998 None => (path, ""),
999 };
1000 let segments: Vec<&str> = path.split('/').collect();
1001 if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
1002 return None;
1003 }
1004 let prefix = &segments[..segments.len() - 2];
1005 let prefix = if prefix.is_empty() {
1006 String::new()
1007 } else {
1008 format!("{}/", prefix.join("/"))
1009 };
1010 Some(format!(
1011 "{}{prefix}{full_name}{suffix}",
1012 &origin[..path_start]
1013 ))
1014}
1015
1016fn fork_reached_like_origin(
1021 owner: String,
1022 full_name: String,
1023 origin_url: &str,
1024 ssh_url: String,
1025 https_url: String,
1026) -> Fork {
1027 let origin = origin_url.trim();
1028 let url = fork_url(origin, &full_name).unwrap_or_else(|| {
1029 if origin.starts_with("git@") || origin.starts_with("ssh://") {
1030 ssh_url
1031 } else {
1032 https_url
1033 }
1034 });
1035 Fork {
1036 owner,
1037 full_name,
1038 url,
1039 }
1040}
1041
1042#[derive(Deserialize)]
1043struct ForkOwner {
1044 login: String,
1045}
1046
1047fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
1049 #[derive(Deserialize)]
1050 struct Answer {
1051 full_name: String,
1052 owner: ForkOwner,
1053 ssh_url: String,
1054 clone_url: String,
1055 }
1056
1057 let answer: Answer = serde_json::from_str(answer.trim())
1058 .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1059 Ok(fork_reached_like_origin(
1060 answer.owner.login,
1061 answer.full_name,
1062 origin_url,
1063 answer.ssh_url,
1064 answer.clone_url,
1065 ))
1066}
1067
1068fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1071 #[derive(Deserialize)]
1072 #[serde(rename_all = "camelCase")]
1073 struct Node {
1074 name_with_owner: String,
1075 owner: ForkOwner,
1076 ssh_url: String,
1077 url: String,
1078 }
1079
1080 let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1081 .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1082 Ok(nodes.into_iter().next().map(|node| {
1083 fork_reached_like_origin(
1084 node.owner.login,
1085 node.name_with_owner,
1086 origin_url,
1087 node.ssh_url,
1088 node.url,
1089 )
1090 }))
1091}
1092
1093fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1096 let owner = pr.head_repository_owner.as_ref()?.login.trim();
1097 let name = pr.head_repository.as_ref()?.name.trim();
1098 if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1099 return None;
1100 }
1101 let full_name = format!("{owner}/{name}");
1102 Some(PushTarget::Fork(Fork {
1103 owner: owner.to_string(),
1104 url: fork_url(origin_url, &full_name)?,
1105 full_name,
1106 }))
1107}
1108
1109fn reconcile_pr_creation(
1110 branch: &str,
1111 created: Result<String>,
1112 found: Result<Option<PrRef>>,
1113) -> Result<PrRef> {
1114 match (created, found) {
1115 (_, Ok(Some(pr))) => Ok(pr),
1116 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1117 "PR creation reported success but none was found for {branch}"
1118 ))),
1119 (Err(create), Ok(None)) => Err(spar_err!(
1120 "could not open a PR for {branch}. {}",
1121 create.last_line()
1122 )),
1123 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1124 "PR creation reported success for {branch}, but it could not be verified. {}",
1125 check.last_line()
1126 ))),
1127 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1128 "could not open a PR for {branch}. {} The result could not be verified: {}",
1129 create.last_line(),
1130 check.last_line()
1131 ))),
1132 }
1133}
1134
1135fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1136 #[derive(Deserialize)]
1137 #[serde(rename_all = "camelCase")]
1138 struct Row {
1139 number: i64,
1140 #[serde(default)]
1141 url: String,
1142 #[serde(default)]
1143 title: String,
1144 base_ref_name: String,
1145 }
1146
1147 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1148 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1149 })?;
1150 Ok(rows
1151 .into_iter()
1152 .find(|row| row.base_ref_name == base)
1153 .map(|row| PrRef {
1154 number: row.number,
1155 url: row.url,
1156 title: row.title,
1157 }))
1158}
1159
1160fn signed(body: &str) -> String {
1163 if body.contains(COMMENT_MARKER) {
1164 return body.to_string();
1165 }
1166 format!("{body}\n\n{COMMENT_MARKER}")
1167}
1168
1169pub fn is_spar_comment(body: &str) -> bool {
1171 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1172}
1173
1174#[derive(Debug, Clone, Default, PartialEq, Eq)]
1176pub struct ApiResponse {
1177 pub status: u16,
1178 pub headers: BTreeMap<String, String>,
1180 pub body: String,
1181}
1182
1183pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1187 let mut lines = stdout.split_inclusive('\n');
1188 let first = lines.next()?.trim_end_matches(['\r', '\n']);
1189 if !first.starts_with("HTTP/") {
1190 return None;
1191 }
1192 let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1193 let mut headers = BTreeMap::new();
1194 let mut body = String::new();
1195 let mut in_body = false;
1196 for line in lines {
1197 if in_body {
1198 body.push_str(line);
1199 continue;
1200 }
1201 let bare = line.trim_end_matches(['\r', '\n']);
1202 if bare.is_empty() {
1203 in_body = true;
1204 continue;
1205 }
1206 if let Some((name, value)) = bare.split_once(':') {
1207 headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1208 }
1209 }
1210 Some(ApiResponse {
1211 status,
1212 headers,
1213 body,
1214 })
1215}
1216
1217const REQUESTS_QUERY: &str = "\
1220query($owner: String!, $repo: String!, $number: Int!) {
1221 repository(owner: $owner, name: $repo) {
1222 pullRequest(number: $number) {
1223 timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1224 nodes {
1225 ... on ReviewRequestedEvent {
1226 createdAt
1227 requestedReviewer {
1228 ... on User { login }
1229 ... on Team { combinedSlug }
1230 }
1231 }
1232 }
1233 }
1234 }
1235 }
1236}";
1237
1238pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1242 let v: Value = serde_json::from_str(text).ok()?;
1243 let nodes = v
1244 .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1245 .as_array()?;
1246 let text_at = |node: &Value, path: &str| {
1247 node.pointer(path)
1248 .and_then(Value::as_str)
1249 .unwrap_or_default()
1250 .to_string()
1251 };
1252 Some(
1253 nodes
1254 .iter()
1255 .map(|node| crate::model::ReviewRequested {
1256 at: text_at(node, "/createdAt"),
1257 who: crate::model::Login {
1258 login: text_at(node, "/requestedReviewer/login"),
1259 slug: text_at(node, "/requestedReviewer/combinedSlug"),
1260 },
1261 })
1262 .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1263 .collect(),
1264 )
1265}
1266
1267pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1271 let stem = name.strip_suffix(".json")?;
1272 ["pr-", "review-", "checkin-"]
1273 .iter()
1274 .find_map(|prefix| stem.strip_prefix(prefix))
1275 .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1276 .and_then(|digits| digits.parse().ok())
1277}
1278
1279fn has_viewer_review(reviews: &[Value], viewer: &str, bodies: &[&str], commit: &str) -> bool {
1287 reviews.iter().any(|review| {
1288 let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1289 let standing = !matches!(
1290 field("state").to_uppercase().as_str(),
1291 "DISMISSED" | "PENDING"
1292 );
1293 review
1294 .get("user")
1295 .and_then(|u| u.get("login"))
1296 .and_then(Value::as_str)
1297 .is_some_and(|login| crate::comments::same_login(login, viewer))
1298 && bodies.contains(&field("body"))
1299 && field("commit_id").eq_ignore_ascii_case(commit)
1300 && standing
1301 })
1302}
1303
1304fn reconcile_review_post(
1305 number: i64,
1306 bodies: &[&str],
1307 viewer: &str,
1308 commit: &str,
1309 post_error: crate::error::SparError,
1310 reviews: Result<Vec<Value>>,
1311) -> Result<()> {
1312 match reviews {
1313 Ok(reviews) if has_viewer_review(&reviews, viewer, bodies, commit) => Ok(()),
1314 Ok(_) => Err(post_error),
1315 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1316 "could not submit a review on #{number}. {} The result could not be verified: {}",
1317 post_error.last_line(),
1318 read_error.last_line()
1319 ))),
1320 }
1321}
1322
1323fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1328 let comments: Vec<Value> = comments
1329 .iter()
1330 .map(|c| {
1331 let mut row = serde_json::json!({
1332 "path": c.path,
1333 "line": c.line,
1334 "side": "RIGHT",
1335 "body": c.body,
1336 });
1337 if let Some(start) = c.start_line {
1338 row["start_line"] = serde_json::json!(start);
1339 row["start_side"] = serde_json::json!("RIGHT");
1340 }
1341 row
1342 })
1343 .collect();
1344 serde_json::json!({
1345 "commit_id": commit,
1346 "event": event,
1347 "body": body,
1348 "comments": comments,
1349 })
1350}
1351
1352fn refused(e: &crate::error::SparError) -> bool {
1356 e.to_string().contains("(HTTP 422)")
1357}
1358
1359pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1363
1364fn new_side_hunks(diff: &str) -> Hunks {
1371 let mut out = Hunks::new();
1372 let mut path: Option<String> = None;
1373 let (mut old_left, mut new_left) = (0u32, 0u32);
1374 for line in diff.lines() {
1375 if old_left > 0 || new_left > 0 {
1376 match line.as_bytes().first() {
1377 Some(b'+') => new_left = new_left.saturating_sub(1),
1378 Some(b'-') => old_left = old_left.saturating_sub(1),
1379 Some(b'\\') => {}
1380 _ => {
1381 old_left = old_left.saturating_sub(1);
1382 new_left = new_left.saturating_sub(1);
1383 }
1384 }
1385 continue;
1386 }
1387 if line.starts_with("diff --git ") {
1388 path = None;
1389 } else if let Some(rest) = line.strip_prefix("+++ ") {
1390 let rest = if rest.contains(' ') {
1393 rest.strip_suffix('\t').unwrap_or(rest)
1394 } else {
1395 rest
1396 };
1397 path = rest
1398 .strip_prefix("b/")
1399 .filter(|p| !p.is_empty())
1400 .map(str::to_string);
1401 } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1402 old_left = old_len;
1403 new_left = len;
1404 if let (Some(path), true) = (&path, len > 0 && start > 0) {
1405 out.entry(path.clone())
1406 .or_default()
1407 .push((start, start + len - 1));
1408 }
1409 }
1410 }
1411 out
1412}
1413
1414fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1416 let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1417 let (old, new) = ranges.split_once(" +")?;
1418 let range = |s: &str| -> Option<(u32, u32)> {
1419 match s.split_once(',') {
1420 Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1421 None => Some((s.parse().ok()?, 1)),
1422 }
1423 };
1424 Some((range(old)?, range(new)?))
1425}
1426
1427fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1428 comments.iter().any(|comment| {
1429 comment
1430 .get("body")
1431 .and_then(Value::as_str)
1432 .is_some_and(|seen| seen == body)
1433 })
1434}
1435
1436fn reconcile_comment_post(
1437 number: i64,
1438 body: &str,
1439 post_error: crate::error::SparError,
1440 comments: Result<Vec<Value>>,
1441) -> Result<()> {
1442 match comments {
1443 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1444 Ok(_) => Err(post_error),
1445 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1446 "could not comment on #{number}. {} The result could not be verified: {}",
1447 post_error.last_line(),
1448 read_error.last_line()
1449 ))),
1450 }
1451}
1452
1453fn reconcile_issue_edit(
1454 number: i64,
1455 wanted: &str,
1456 edit_error: crate::error::SparError,
1457 observed: Result<String>,
1458) -> Result<()> {
1459 match observed {
1460 Ok(body) if body == wanted => Ok(()),
1461 Ok(_) => Err(spar_err!(
1462 "could not rewrite the body of #{number}. {}",
1463 edit_error.last_line()
1464 )),
1465 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1466 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1467 edit_error.last_line(),
1468 read_error.last_line()
1469 ))),
1470 }
1471}
1472
1473fn issue_url_has_number(url: &str) -> bool {
1474 url.trim()
1475 .rsplit('/')
1476 .next()
1477 .and_then(|tail| tail.parse::<i64>().ok())
1478 .is_some_and(|number| number > 0)
1479}
1480
1481fn reconcile_issue_creation(
1482 title: &str,
1483 created: Result<String>,
1484 found: Result<Option<ExistingIssue>>,
1485) -> Result<String> {
1486 match (created, found) {
1487 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1488 (_, Ok(Some(issue))) => Ok(issue.url),
1489 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1490 "issue creation reported success but no matching issue was found for {title:?}"
1491 ))),
1492 (Err(create), Ok(None)) => Err(spar_err!(
1493 "could not file issue {title:?}. {}",
1494 create.last_line()
1495 )),
1496 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1497 "issue creation reported success for {title:?}, but it could not be verified. {}",
1498 check.last_line()
1499 ))),
1500 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1501 "could not file issue {title:?}. {} The result could not be verified: {}",
1502 create.last_line(),
1503 check.last_line()
1504 ))),
1505 }
1506}
1507
1508fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1509 if output.trim().is_empty() {
1510 return Ok(None);
1511 }
1512 for line in output.lines() {
1513 let mut fields = line.split_whitespace();
1514 let oid = fields.next().unwrap_or_default();
1515 let name = fields.next().unwrap_or_default();
1516 if name == remote_ref && !oid.is_empty() {
1517 return Ok(Some(oid.to_string()));
1518 }
1519 }
1520 Err(spar_err!(
1521 "origin returned an unexpected ref listing for {remote_ref}"
1522 ))
1523}
1524
1525fn reconcile_failed_split_push(
1526 branch: &str,
1527 push_error: crate::error::SparError,
1528 local: Result<String>,
1529 remote: Result<String>,
1530) -> std::result::Result<(), SplitPushError> {
1531 let remote_ref = format!("refs/heads/{branch}");
1532 match (local, remote) {
1533 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1534 Ok(Some(oid)) if oid == local.trim() => Ok(()),
1535 Ok(_) => Err(SplitPushError::new(
1536 format!(
1537 "could not create origin/{branch}. {} The remote branch is absent or points \
1538 somewhere else. Nothing was overwritten.",
1539 push_error.last_line()
1540 ),
1541 false,
1542 )),
1543 Err(check) => Err(SplitPushError::new(
1544 format!(
1545 "could not confirm whether origin/{branch} was created. {} The remote result \
1546 could not be verified: {}",
1547 push_error.last_line(),
1548 check.last_line()
1549 ),
1550 true,
1551 )),
1552 },
1553 (local, remote) => {
1554 let check = match (local, remote) {
1555 (Err(local), Err(remote)) => format!(
1556 "the local commit could not be read: {}; origin could not be read: {}",
1557 local.last_line(),
1558 remote.last_line()
1559 ),
1560 (Err(local), _) => {
1561 format!("the local commit could not be read: {}", local.last_line())
1562 }
1563 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1564 _ => unreachable!(),
1565 };
1566 Err(SplitPushError::new(
1567 format!(
1568 "could not confirm whether origin/{branch} was created. {} The result could \
1569 not be verified because {check}",
1570 push_error.last_line()
1571 ),
1572 true,
1573 ))
1574 }
1575 }
1576}
1577
1578impl Repo {
1579 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1580 let root =
1581 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1582 let inside = proc::run_str(
1585 &["git", "rev-parse", "--is-inside-work-tree"],
1586 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1587 )
1588 .unwrap_or_default();
1589 if inside.trim() != "true" {
1590 bail!("not a git repository: {}", root.display());
1591 }
1592 let repo = Self {
1593 root,
1594 style: cfg.style.clone(),
1595 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1596 state_store: cfg.loop_cfg.state_store,
1597 followups: cfg.loop_cfg.followups,
1598 drafts: cfg.loop_cfg.drafts,
1599 fork_when_denied: cfg.loop_cfg.fork_when_denied,
1600 setup: cfg.loop_cfg.setup.clone(),
1601 isolate_reads: cfg.loop_cfg.worktrees,
1602 keep_worktrees: cfg.loop_cfg.keep_worktrees,
1603 fork: OnceLock::new(),
1604 viewer: OnceLock::new(),
1605 checkpoints: Mutex::new(BTreeMap::new()),
1606 writes: WriteStats::default(),
1607 };
1608 repo.self_exclude();
1609 Ok(repo)
1610 }
1611
1612 fn self_exclude(&self) {
1620 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1621 let git_dir = git_dir.trim();
1622 if git_dir.is_empty() {
1623 return;
1624 }
1625 let path = Path::new(git_dir).join("info").join("exclude");
1626 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1627
1628 let wanted = [
1633 format!("/{WORKTREE_DIR}/"),
1634 format!("/{STATE_DIR}/"),
1635 format!("{RECOVERY_MARKER_PREFIX}*"),
1636 ];
1637 let missing: Vec<&String> = wanted
1638 .iter()
1639 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1640 .collect();
1641 if missing.is_empty() {
1642 return;
1643 }
1644
1645 use std::io::Write;
1646 if let Some(parent) = path.parent() {
1647 let _ = std::fs::create_dir_all(parent);
1648 }
1649 let mut block = String::new();
1650 if !existing.is_empty() && !existing.ends_with('\n') {
1651 block.push('\n');
1652 }
1653 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1654 for line in missing {
1655 block.push_str(line);
1656 block.push('\n');
1657 }
1658 if let Ok(mut file) = std::fs::OpenOptions::new()
1659 .create(true)
1660 .append(true)
1661 .open(&path)
1662 {
1663 let _ = file.write_all(block.as_bytes());
1664 }
1665 }
1666
1667 pub fn root(&self) -> &Path {
1668 &self.root
1669 }
1670
1671 pub(crate) fn write_summary(&self) -> WriteSummary {
1672 WriteSummary {
1673 attempted: self.writes.attempted.load(Ordering::Relaxed),
1674 failed: self.writes.failed.load(Ordering::Relaxed),
1675 }
1676 }
1677
1678 pub(crate) fn record_write<T, E>(
1679 &self,
1680 result: std::result::Result<T, E>,
1681 ) -> std::result::Result<T, E> {
1682 self.record_write_outcome(result.is_err());
1683 result
1684 }
1685
1686 pub(crate) fn record_failed_write<T, E>(
1687 &self,
1688 result: std::result::Result<T, E>,
1689 ) -> std::result::Result<T, E> {
1690 if result.is_err() {
1691 self.record_write_outcome(true);
1692 }
1693 result
1694 }
1695
1696 fn record_write_outcome(&self, failed: bool) {
1697 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1698 if failed {
1699 self.writes.failed.fetch_add(1, Ordering::Relaxed);
1700 }
1701 }
1702
1703 pub fn clean(&self, text: &str) -> Result<String> {
1709 let out = style::scrub(text, &self.style);
1710 let bad = style::violations(&out, &self.style);
1711 if !bad.is_empty() {
1712 bail!(
1713 "style gate could not clean text ({}): {}",
1714 bad.join(", "),
1715 style::clip(&out, 300)
1716 );
1717 }
1718 Ok(out)
1719 }
1720
1721 pub fn clean_body(&self, text: &str) -> Result<String> {
1723 self.clean(&style::body(text, &self.style))
1724 }
1725
1726 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1728 self.clean(&style::issue_body(text, &self.style))
1729 }
1730
1731 pub fn clean_title(&self, text: &str) -> Result<String> {
1742 Ok(style::title(&self.clean(text)?, &self.style))
1743 }
1744
1745 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1746 let title = self.record_failed_write(self.clean_title(text))?;
1747 if title.trim().is_empty() {
1748 return self.record_failed_write(Err(spar_err!(
1749 "nothing left of the title after cleaning it"
1750 )));
1751 }
1752 Ok(title)
1753 }
1754
1755 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1756 if self.followups == Followups::Issues {
1757 self.clean_nonempty_title_for_write(text)
1758 } else {
1759 self.clean_title(text)
1760 }
1761 }
1762
1763 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1766 ExecOpts::new()
1767 .cwd(cwd.unwrap_or(&self.root))
1768 .check(check)
1769 .timeout_secs(600)
1770 }
1771
1772 pub fn git(&self, args: &[&str]) -> Result<String> {
1773 self.git_at(None, args)
1774 }
1775
1776 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1777 let argv = git_without_maintenance_argv(args);
1778 proc::run(&argv, &self.git_opts(cwd, true))
1779 }
1780
1781 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1787 let argv = git_without_automation_argv(args);
1788 proc::run(
1789 &argv,
1790 &self.git_opts(Some(cwd), true).stop_descendants(true),
1791 )
1792 }
1793
1794 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1795 let argv = git_without_automation_argv(args);
1796 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1797 .map(|output| output.ok())
1798 }
1799
1800 pub fn git_try(&self, args: &[&str]) -> String {
1802 self.git_try_at(None, args)
1803 }
1804
1805 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1806 let argv = git_without_maintenance_argv(args);
1807 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1808 }
1809
1810 pub fn default_branch(&self, configured: &str) -> String {
1813 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1814 match refname.trim().rsplit('/').next() {
1815 Some(name) if !name.is_empty() => name.to_string(),
1816 _ => configured.to_string(),
1817 }
1818 }
1819
1820 pub fn branch_for_issue(&self, issue: i64) -> String {
1828 format!("{}issue-{issue}", self.branch_prefix)
1829 }
1830
1831 pub fn branch_for_pr(&self, number: i64) -> String {
1832 format!("{}pr-{number}", self.branch_prefix)
1833 }
1834
1835 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1844 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1845 }
1846
1847 fn ledger_path(&self) -> PathBuf {
1848 self.root.join(STATE_DIR).join("branches.json")
1849 }
1850
1851 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1852 std::fs::read_to_string(self.ledger_path())
1853 .ok()
1854 .and_then(|text| serde_json::from_str(&text).ok())
1855 .unwrap_or_default()
1856 }
1857
1858 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1859 let mut data = self.known_branches();
1860 data.insert(
1861 branch.to_string(),
1862 BranchRecord {
1863 kind: kind.to_string(),
1864 number,
1865 },
1866 );
1867 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1868 logdim!("could not record branch {branch}: {e}");
1869 }
1870 }
1871
1872 pub fn forget_branch(&self, branch: &str) {
1873 let mut data = self.known_branches();
1874 if data.remove(branch).is_none() {
1875 return;
1876 }
1877 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1878 logdim!("could not update the branch record: {e}");
1879 }
1880 }
1881
1882 fn worktree_path(&self, name: &str) -> PathBuf {
1885 self.root.join(WORKTREE_DIR).join(name)
1886 }
1887
1888 fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
1895 let argv = self.setup.argv();
1896 if argv.is_empty() {
1897 return Ok(());
1898 }
1899 let command = self.setup.describe();
1900 logdim!("setting up the {label}: {command}");
1901 let out = proc::exec(
1902 &argv,
1903 &ExecOpts::new()
1904 .cwd(path)
1905 .check(false)
1906 .timeout_secs(SETUP_TIMEOUT_SECS)
1907 .stop_descendants(true),
1908 )
1909 .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
1910 if !out.ok() {
1911 let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
1912 bail!(
1913 "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
1914 work in it.\n{}",
1915 path.display(),
1916 style::clip(text.trim(), 4000)
1917 );
1918 }
1919 Ok(())
1920 }
1921
1922 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1924 let branch = self.branch_for_issue(issue);
1925 let path = self.worktree_path(&format!("issue-{issue}"));
1926
1927 self.refuse_issue_branch_rebuild(issue, base)?;
1928 self.refuse_dirty_worktree(
1929 &path,
1930 &format!("worktree for issue #{issue}"),
1931 Disposable::BuildOutput,
1932 )?;
1933
1934 if !self.branch_deletion_is_safe(&branch)? {
1935 bail!(
1936 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1937 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1938 before retrying."
1939 );
1940 }
1941
1942 if !self.remove_worktree_at(&path)? {
1943 bail!(
1944 "the existing worktree for issue #{issue} could not be removed safely. Its \
1945 branch was kept."
1946 );
1947 }
1948 if !self.delete_branch_if_safe(&branch)? {
1949 bail!(
1950 "the existing branch {branch} changed or remained checked out while its \
1951 worktree was being rebuilt. It was kept."
1952 );
1953 }
1954
1955 if let Some(parent) = path.parent() {
1956 std::fs::create_dir_all(parent)
1957 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1958 }
1959
1960 let path_str = path.display().to_string();
1961 let remote_start = format!("origin/{base}");
1962 let created = self
1963 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1964 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1965
1966 created.map_err(|e| {
1969 spar_err!(
1970 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1971 and does `origin` exist?",
1972 e.last_line()
1973 )
1974 })?;
1975 self.record_branch(&branch, "issue", issue);
1976 self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
1977 Ok((path, branch))
1978 }
1979
1980 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1987 let branch = self.branch_for_issue(issue);
1988 let base_remote_ref = format!("refs/heads/{base}");
1989 let base_tracking_ref = format!("refs/remotes/origin/{base}");
1990 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1991 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1992 .map_err(|e| {
1993 spar_err!(
1994 "could not refresh origin/{base} before checking issue #{issue}: {}",
1995 e.last_line()
1996 )
1997 })?;
1998
1999 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
2000 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
2001 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
2002 bail!(
2003 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
2004 pull request accounts for them. Rebuilding it would force push over that \
2005 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
2006 it, or delete it with `git push origin --delete {branch}` if the remote \
2007 branch is no longer needed."
2008 );
2009 }
2010 }
2011
2012 if self.fork_when_denied && self.origin_refuses_push() {
2015 match self.existing_fork() {
2016 Ok(Some(fork)) => {
2017 self.fork_branch_lease(&fork, &branch, base)?;
2018 }
2019 Ok(None) => {}
2020 Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
2021 }
2022 }
2023
2024 let local_ref = format!("refs/heads/{branch}");
2025 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2026 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
2027 let recorded_pr = self
2028 .known_branches()
2029 .get(&branch)
2030 .is_some_and(|record| record.kind == "pr");
2031 let preserved = ahead == 0
2032 || if recorded_pr {
2033 self.local_branch_is_preserved(&branch)?
2034 } else {
2035 self.pull_request_holds(&branch, &local_ref, base)
2036 };
2037 if !preserved {
2038 let listed = self
2039 .commit_lines(&self.root, &local_ref, base)
2040 .iter()
2041 .map(|line| format!(" {line}"))
2042 .collect::<Vec<_>>()
2043 .join("\n");
2044 bail!(
2045 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
2046 no pull request preserves them. Rebuilding it would delete the only copy.\n\
2047 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
2048 it, or delete it with `git branch -D {branch}` if it is stale."
2049 );
2050 }
2051 }
2052 Ok(())
2053 }
2054
2055 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2056 let live_ref = format!("refs/heads/{branch}");
2057 let tracking_ref = format!("refs/remotes/origin/{branch}");
2058 let listed = self
2059 .git(&["ls-remote", "--heads", "origin", &live_ref])
2060 .map_err(|e| {
2061 spar_err!(
2062 "could not verify whether origin/{branch} still exists: {}",
2063 e.last_line()
2064 )
2065 })?;
2066
2067 if remote_head_oid(&listed, &live_ref)?.is_some() {
2068 let refspec = format!("+{live_ref}:{tracking_ref}");
2069 self.git(&["fetch", "--no-tags", "origin", &refspec])
2070 .map_err(|e| {
2071 spar_err!(
2072 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2073 e.last_line()
2074 )
2075 })?;
2076 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2077 bail!("origin/{branch} was fetched but its tracking ref is missing");
2078 }
2079 return Ok(Some(tracking_ref));
2080 }
2081
2082 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2083 return Ok(None);
2084 }
2085 let expected = self
2086 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2087 .trim()
2088 .to_string();
2089 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2090 .map_err(|e| {
2091 spar_err!(
2092 "could not discard stale origin/{branch} tracking ref safely: {}",
2093 e.last_line()
2094 )
2095 })?;
2096 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2097 bail!(
2098 "origin/{branch} changed while its stale tracking ref was being removed. It was \
2099 kept."
2100 );
2101 }
2102 Ok(None)
2103 }
2104
2105 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2114 self.prs_for_branch(branch)
2115 .iter()
2116 .any(|pr| self.pr_head_holds(pr.number, refname, base))
2117 }
2118
2119 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2120 let head = format!("refs/spar/pr-head/{number}");
2121 let refspec = format!("+refs/pull/{number}/head:{head}");
2122 if self.git(&["fetch", "origin", &refspec]).is_err() {
2123 return false;
2124 }
2125 let held = self.commits_held_by(refname, base, &head);
2126 self.git_try(&["update-ref", "-d", &head]);
2127 held
2128 }
2129
2130 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2131 let argv = vec![
2132 "git".to_string(),
2133 "merge-base".to_string(),
2134 "--is-ancestor".to_string(),
2135 older.to_string(),
2136 newer.to_string(),
2137 ];
2138 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2139 match out.code {
2140 0 => Ok(true),
2141 1 => Ok(false),
2142 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2143 }
2144 }
2145
2146 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2147 let head = format!("refs/spar/pr-head/{number}");
2148 let refspec = format!("+refs/pull/{number}/head:{head}");
2149 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2150 spar_err!(
2151 "could not verify the immutable head of PR #{number}: {}",
2152 e.last_line()
2153 )
2154 })?;
2155 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2156 self.git_try(&["update-ref", "-d", &head]);
2157 held
2158 }
2159
2160 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2161 let text = self.gh(&[
2162 "pr",
2163 "list",
2164 "--head",
2165 branch,
2166 "--state",
2167 "all",
2168 "--json",
2169 "number,url,title",
2170 ])?;
2171 serde_json::from_str(text.trim())
2172 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2173 }
2174
2175 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2176 let branch_ref = format!("refs/heads/{branch}");
2177 if record.kind == "pr" {
2178 return self.pr_head_contains_checked(record.number, &branch_ref);
2179 }
2180 let prs = self.branch_prs_checked(branch)?;
2181 if prs.is_empty() {
2182 return Ok(false);
2183 }
2184 for pr in prs {
2185 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2186 return Ok(true);
2187 }
2188 }
2189 Ok(false)
2190 }
2191
2192 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2193 let local_ref = format!("refs/heads/{branch}");
2194 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2195 return Ok(true);
2196 }
2197 let oid = self
2198 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2199 .trim()
2200 .to_string();
2201 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2202 if !durable_tip {
2203 let remote_ref = format!("refs/heads/{branch}");
2204 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2205 durable_tip = remote.lines().any(|line| {
2206 line.split_whitespace()
2207 .next()
2208 .is_some_and(|remote_oid| remote_oid == oid)
2209 });
2210 }
2211 if !durable_tip {
2212 if let Some(record) = self.known_branches().get(branch) {
2213 durable_tip = self.branch_is_preserved_checked(branch, record)?;
2214 }
2215 }
2216 if !durable_tip {
2217 return Ok(false);
2218 }
2219 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2220 }
2221
2222 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2226 let local_ref = format!("refs/heads/{branch}");
2227 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2228 return Ok(true);
2229 }
2230 let expected = self
2231 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2232 .trim()
2233 .to_string();
2234 if !self.branch_deletion_is_safe(branch)? {
2235 return Ok(false);
2236 }
2237 let checked_out = self
2238 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2239 .lines()
2240 .any(|line| line == format!("branch {local_ref}"));
2241 if checked_out {
2242 return Ok(false);
2243 }
2244 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2245 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2246 }
2247
2248 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2249 let local_ref = review_ref(number);
2250 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2251 return Ok(true);
2252 }
2253 let oid = self
2254 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2255 .trim()
2256 .to_string();
2257 if !self.pr_head_contains_checked(number, &local_ref)? {
2258 return Ok(false);
2259 }
2260 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2261 }
2262
2263 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2264 let local_ref = review_ref(number);
2265 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2266 return Ok(true);
2267 }
2268 let expected = self
2269 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2270 .trim()
2271 .to_string();
2272 if !self.review_ref_deletion_is_safe(number)? {
2273 return Ok(false);
2274 }
2275 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2276 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2277 }
2278
2279 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2283 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2284 self.git_try(&["rev-list", "--count", &range, "--not", other])
2285 .trim()
2286 == "0"
2287 }
2288
2289 pub fn worktree_remove(&self, issue: i64) -> bool {
2290 let path = self.worktree_path(&format!("issue-{issue}"));
2291 match self.remove_worktree_at(&path) {
2292 Ok(removed) => removed,
2293 Err(error) => {
2294 logdim!(
2295 "kept {} because removal did not reach a confirmed quiet point: {}",
2296 path.display(),
2297 error.last_line()
2298 );
2299 false
2300 }
2301 }
2302 }
2303
2304 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2309 let wanted = std::fs::canonicalize(path)
2310 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2311 if wanted != path {
2316 return Ok(false);
2317 }
2318 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2319 let raw = PathBuf::from(value.trim());
2320 let joined = if raw.is_absolute() {
2321 raw
2322 } else {
2323 cwd.join(raw)
2324 };
2325 std::fs::canonicalize(&joined)
2326 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2327 };
2328 let expected =
2329 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2330 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2331 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2332 let expected = resolve(&self.root, &expected)?;
2333 let actual = resolve(path, &actual)?;
2334 let top = resolve(path, &top)?;
2335 Ok(expected == actual && top == wanted)
2336 }
2337
2338 fn remove_worktree_at_with_force(
2344 &self,
2345 path: &Path,
2346 force: bool,
2347 disposable: Disposable,
2348 ) -> Result<bool> {
2349 let existed = path.exists();
2350 if path.exists() {
2351 match self.worktree_belongs_to_repo(path) {
2352 Ok(true) => {}
2353 Ok(false) => {
2354 logdim!(
2355 "kept {} because it is not a worktree owned by this repository",
2356 path.display()
2357 );
2358 return Ok(false);
2359 }
2360 Err(e) => {
2361 logdim!(
2362 "kept {} because its worktree ownership could not be verified: {}",
2363 path.display(),
2364 e.last_line()
2365 );
2366 return Ok(false);
2367 }
2368 }
2369 if !force {
2370 match self.has_recoverable_work(path, disposable) {
2371 Ok(true) => {
2372 logdim!(
2373 "kept {} because it contains recoverable files or repository state",
2374 path.display()
2375 );
2376 return Ok(false);
2377 }
2378 Err(e) => {
2379 logdim!(
2380 "kept {} because its recoverable state could not be checked: {}",
2381 path.display(),
2382 e.last_line()
2383 );
2384 return Ok(false);
2385 }
2386 Ok(false) => {}
2387 }
2388 }
2389 }
2390 let path_str = path.display().to_string();
2396 let command_ok = if force || disposable == Disposable::ReviewOutput {
2397 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2398 } else {
2399 self.git_try_without_automation(&["worktree", "remove", &path_str])?
2400 };
2401 Ok((command_ok || !existed) && !path.exists())
2402 }
2403
2404 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2405 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2406 }
2407
2408 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2410 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2411 }
2412
2413 fn remove_worktree_at_force(&self, path: &Path) -> bool {
2415 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2416 Ok(removed) => removed,
2417 Err(error) => {
2418 logdim!(
2419 "kept {} because removal did not reach a confirmed quiet point: {}",
2420 path.display(),
2421 error.last_line()
2422 );
2423 false
2424 }
2425 }
2426 }
2427
2428 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2433 if path.exists() && !self.worktree_belongs_to_repo(path)? {
2434 bail!(
2435 "{} is not a worktree owned by this repository, so it was kept",
2436 path.display()
2437 );
2438 }
2439 if path.exists() && self.has_recoverable_work(path, disposable)? {
2440 bail!(
2441 "the verified worktree at {} contains recoverable files or repository state. It \
2442 was kept.",
2443 path.display()
2444 );
2445 }
2446 let path_str = path.display().to_string();
2447 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2448 .map_err(|e| {
2449 e.with_message(format!(
2450 "could not remove the verified worktree at {}: {}. It was kept.",
2451 path.display(),
2452 e.last_line()
2453 ))
2454 })?;
2455 Ok(!path.exists())
2456 }
2457
2458 fn refuse_dirty_worktree(
2459 &self,
2460 path: &Path,
2461 label: &str,
2462 disposable: Disposable,
2463 ) -> Result<()> {
2464 if !path.is_dir() {
2465 return Ok(());
2466 }
2467 let has_files = std::fs::read_dir(path)
2468 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2469 .next()
2470 .is_some();
2471 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2472 spar_err!(
2473 "could not verify whether the existing {label} at {} belongs to this repository, \
2474 so it was kept: {}",
2475 path.display(),
2476 e.last_line()
2477 )
2478 })?;
2479 if !owned {
2480 if has_files {
2481 bail!(
2482 "the existing {label} at {} is not a worktree owned by \
2483 this repository. Refusing to remove it.",
2484 path.display()
2485 );
2486 }
2487 return Ok(());
2488 }
2489 if !path.join(".git").exists() {
2490 if has_files {
2491 bail!(
2492 "the existing {label} at {} is not a readable Git worktree and is not empty. \
2493 Refusing to remove it.",
2494 path.display()
2495 );
2496 }
2497 return Ok(());
2498 }
2499 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2500 spar_err!(
2501 "could not verify whether the existing {label} at {} is clean, so it was kept: \
2502 {}",
2503 path.display(),
2504 e.last_line()
2505 )
2506 })?;
2507 if dirty {
2508 bail!(
2509 "the existing {label} contains uncommitted changes or ignored files at {}. \
2510 Rebuilding it would delete those files.\nCommit or recover them before running this \
2511 command again, or use `spar clean --all` if they are not needed.",
2512 path.display()
2513 );
2514 }
2515 Ok(())
2516 }
2517
2518 pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2521 let head = pr.head_ref_name.clone();
2522 if head.trim().is_empty() {
2523 bail!("PR #{} has no head branch to check out", pr.number);
2524 }
2525 let path = self.worktree_path(&format!("pr-{}", pr.number));
2526 let local = self.branch_for_pr(pr.number);
2527
2528 let unfetched = |e: crate::error::SparError| {
2529 spar_err!(
2530 "could not fetch the branch behind PR #{}: {}",
2531 pr.number,
2532 e.last_line()
2533 )
2534 };
2535 let (start, start_ref, shown) = match target {
2538 PushTarget::Origin => {
2539 self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2540 let start = format!("origin/{head}");
2541 (start.clone(), format!("refs/remotes/origin/{head}"), start)
2542 }
2543 PushTarget::Fork(fork) => {
2546 let start_ref = format!("refs/spar/fork/{head}");
2547 let refspec = format!("+refs/heads/{head}:{start_ref}");
2548 self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2549 .map_err(unfetched)?;
2550 (start_ref.clone(), start_ref, target.describe(&head))
2551 }
2552 };
2553 let local_ref = format!("refs/heads/{local}");
2554 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2555 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2556 if unpushed > 0 {
2557 bail!(
2558 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2559 on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2560 {} and push or recover those commits before running this command again.",
2561 pr.number,
2562 path.display()
2563 );
2564 }
2565 }
2566 self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2567 self.refuse_dirty_worktree(
2568 &path,
2569 &format!("worktree for PR #{}", pr.number),
2570 Disposable::BuildOutput,
2571 )?;
2572 if !self.branch_deletion_is_safe(&local)? {
2573 bail!(
2574 "the existing branch {local} has a tip or reflog-only commit that no surviving \
2575 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2576 before retrying."
2577 );
2578 }
2579 if !self.remove_worktree_at(&path)? {
2580 bail!(
2581 "the existing worktree for PR #{} could not be removed safely. Its branch was \
2582 kept.",
2583 pr.number
2584 );
2585 }
2586 if !self.delete_branch_if_safe(&local)? {
2587 bail!(
2588 "the existing branch {local} changed or remained checked out while the PR \
2589 worktree was being rebuilt. It was kept."
2590 );
2591 }
2592
2593 let path_str = path.display().to_string();
2594 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2595 self.record_branch(&local, "pr", pr.number);
2596 self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2597 Ok((path, head))
2598 }
2599
2600 fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2603 if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2604 return Ok(());
2605 }
2606 let current =
2607 self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2608 if current.trim() != branch
2609 || self.has_uncommitted_changes(path)?
2610 || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2611 {
2612 return Ok(());
2613 }
2614 static NEXT: AtomicUsize = AtomicUsize::new(0);
2615 let (name, retained) = loop {
2616 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2617 let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2618 let retained = self.worktree_path(&name);
2619 if !retained
2620 .try_exists()
2621 .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2622 && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2623 {
2624 break (name, retained);
2625 }
2626 };
2627 self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2630 if let Err(error) = self.git_at_without_automation(
2631 &self.root,
2632 &[
2633 "worktree",
2634 "move",
2635 &path.display().to_string(),
2636 &retained.display().to_string(),
2637 ],
2638 ) {
2639 let rollback =
2642 self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2643 let detail = rollback
2644 .err()
2645 .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2646 .unwrap_or_default();
2647 bail!(
2648 "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2649 retained.display(),
2650 error.last_line()
2651 );
2652 }
2653 logdim!(
2654 "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2655 retained.display()
2656 );
2657 Ok(())
2658 }
2659
2660 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2670 let path = self.worktree_path(&format!("review-{number}"));
2671 let local_ref = review_ref(number);
2672 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2673
2674 self.refuse_review_worktree_changes(number)?;
2675
2676 if let Some(parent) = path.parent() {
2677 std::fs::create_dir_all(parent)
2678 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2679 }
2680 if !self.remove_review_worktree_at(&path)? {
2685 bail!(
2686 "the existing review worktree for PR #{number} could not be removed safely. Its \
2687 reference was kept."
2688 );
2689 }
2690
2691 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2692 spar_err!(
2693 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2694 every pull request, so this usually means the number is wrong or `origin` does \
2695 not point at the repository the PR is on.",
2696 e.last_line()
2697 )
2698 })?;
2699
2700 let path_str = path.display().to_string();
2701 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2702 self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2703 Ok(path)
2704 }
2705
2706 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2707 let path = self.worktree_path(&format!("review-{number}"));
2708 if !path.is_dir() {
2709 return Ok(());
2710 }
2711 let local_ref = review_ref(number);
2712 if !self.worktree_belongs_to_repo(&path)? {
2713 return Ok(());
2714 }
2715 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2716 bail!(
2717 "the existing review worktree for PR #{number} has no recorded head at \
2718 {local_ref}. Refusing to rebuild {}.",
2719 path.display()
2720 );
2721 }
2722 let worktree_head = self.head_oid_checked(&path)?;
2723 let recorded_head = self
2724 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2725 .trim()
2726 .to_string();
2727 if worktree_head != recorded_head {
2728 bail!(
2729 "the existing review worktree for PR #{number} has a local commit that is not on \
2730 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2731 {} before retrying.",
2732 path.display()
2733 );
2734 }
2735 self.report_recovery_markers(&path);
2736 self.refuse_dirty_worktree(
2737 &path,
2738 &format!("review worktree for PR #{number}"),
2739 Disposable::ReviewOutput,
2740 )?;
2741 Ok(())
2742 }
2743
2744 fn report_recovery_markers(&self, path: &Path) {
2752 let Ok(entries) = std::fs::read_dir(path) else {
2753 return;
2754 };
2755 for entry in entries.flatten() {
2756 let name = entry.file_name();
2757 let Some(name) = name.to_str() else { continue };
2758 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2759 continue;
2760 }
2761 match std::fs::read_to_string(entry.path()) {
2762 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2763 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2764 }
2765 }
2766 }
2767
2768 pub fn read_checkout(&self, label: &str) -> Result<ReadCheckout<'_>> {
2774 let shared = || ReadCheckout {
2775 repo: self,
2776 path: self.root.clone(),
2777 checkpoint: None,
2778 };
2779 if !self.isolate_reads {
2780 return Ok(shared());
2781 }
2782 let Some(head) = resolve_optional_commit(&self.root, "HEAD")? else {
2783 return Ok(shared());
2784 };
2785 static NEXT: AtomicUsize = AtomicUsize::new(0);
2786 let path = loop {
2787 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2788 let path = self.worktree_path(&format!(
2789 "{READ_CHECKOUT_PREFIX}{}-{serial}",
2790 std::process::id()
2791 ));
2792 if !path
2793 .try_exists()
2794 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2795 {
2796 break path;
2797 }
2798 };
2799 if let Some(parent) = path.parent() {
2800 std::fs::create_dir_all(parent)
2801 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2802 }
2803 let path_str = path.display().to_string();
2804 self.git(&["worktree", "add", "--detach", &path_str, &head])
2805 .map_err(|e| {
2806 spar_err!(
2807 "could not check HEAD out for the {label}: {}",
2808 e.last_line()
2809 )
2810 })?;
2811 let ready = self
2812 .set_up_worktree(&path, &format!("read-only checkout for the {label}"))
2813 .and_then(|()| self.worktree_checkpoint(&path));
2814 match ready {
2815 Ok(checkpoint) => Ok(ReadCheckout {
2816 repo: self,
2817 path,
2818 checkpoint: Some(checkpoint),
2819 }),
2820 Err(e) => {
2821 self.discard_read_checkout(&path);
2823 Err(e)
2824 }
2825 }
2826 }
2827
2828 fn release_read_checkout(&self, path: &Path, checkpoint: &WorktreeCheckpoint) {
2835 if self.keep_worktrees {
2836 logdim!("kept the read-only checkout at {}", path.display());
2837 return;
2838 }
2839 if has_recovery_marker(path) {
2840 logdim!(
2841 "kept the read-only checkout at {} for recovery",
2842 path.display()
2843 );
2844 return;
2845 }
2846 if let Err(e) = self.require_unchanged_worktree(path, checkpoint, "read-only checkout") {
2847 logwarn!("{e}");
2848 return;
2849 }
2850 self.discard_read_checkout(path);
2851 }
2852
2853 fn discard_read_checkout(&self, path: &Path) {
2854 match self.remove_worktree_at_checked(path, Disposable::ReviewOutput) {
2855 Ok(true) => {}
2856 Ok(false) => logdim!(
2857 "kept the read-only checkout at {} because it was still there after removal",
2858 path.display()
2859 ),
2860 Err(e) => logdim!(
2861 "kept the read-only checkout at {}: {}",
2862 path.display(),
2863 e.last_line()
2864 ),
2865 }
2866 }
2867
2868 pub fn worktree_for_split(
2881 &self,
2882 parent: i64,
2883 index: usize,
2884 start: &str,
2885 ) -> Result<(PathBuf, String)> {
2886 let slot = self.free_split_slot(parent, index)?;
2887 let branch = format!("{}{slot}", self.branch_prefix);
2888 let path = self.worktree_path(&slot);
2889
2890 if let Some(dir) = path.parent() {
2891 std::fs::create_dir_all(dir)
2892 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2893 }
2894 self.refuse_dirty_worktree(
2895 &path,
2896 &format!("worktree for part {index} of PR #{parent}"),
2897 Disposable::BuildOutput,
2898 )?;
2899 if !self.remove_worktree_at(&path)? {
2903 bail!(
2904 "the existing worktree for part {index} of PR #{parent} could not be removed \
2905 safely. No branch was created."
2906 );
2907 }
2908
2909 let path_str = path.display().to_string();
2910 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2911 .map_err(|e| {
2912 spar_err!(
2913 "could not create a worktree for part {index} of #{parent}. {}",
2914 e.last_line()
2915 )
2916 })?;
2917 self.record_branch(&branch, "split", parent);
2920 self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
2921 Ok((path, branch))
2922 }
2923
2924 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2931 for attempt in 1..=SPLIT_SLOTS {
2932 let slot = split_slot(parent, index, attempt);
2933 let branch = format!("{}{slot}", self.branch_prefix);
2934 self.git_try(&["fetch", "origin", &branch]);
2935 if !self.rev_exists(&self.root, &branch)
2936 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2937 {
2938 return Ok(slot);
2939 }
2940 }
2941 bail!(
2942 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2943 names are all taken. Inspect the existing branches and child pull requests. Finish \
2944 recording the earlier split, or remove every retained local worktree and branch, \
2945 child pull request, and remote split branch before starting over.",
2946 self.branch_for_split(parent, index)
2947 )
2948 }
2949
2950 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2956 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2957 Ok(!self
2958 .git(&["ls-remote", "--heads", "origin", &pattern])?
2959 .trim()
2960 .is_empty())
2961 }
2962
2963 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2970 match self.branch_deletion_is_safe(branch) {
2971 Ok(true) => {}
2972 Ok(false) => {
2973 logdim!(
2974 "kept {branch} and {} because no surviving ref preserves its tip",
2975 dir.display()
2976 );
2977 return;
2978 }
2979 Err(error) => {
2980 logdim!(
2981 "kept {branch} and {} because preservation could not be verified: {}",
2982 dir.display(),
2983 error.last_line()
2984 );
2985 return;
2986 }
2987 }
2988 match self.remove_worktree_at(dir) {
2989 Ok(true) => match self.delete_branch_if_safe(branch) {
2990 Ok(true) => self.forget_branch(branch),
2991 Ok(false) => {
2992 logdim!("kept {branch} because its tip or reflog changed before deletion")
2993 }
2994 Err(error) => logdim!(
2995 "kept {branch} because deletion safety could not be rechecked: {}",
2996 error.last_line()
2997 ),
2998 },
2999 Ok(false) => {}
3000 Err(error) => logdim!(
3001 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
3002 dir.display(),
3003 error.last_line()
3004 ),
3005 }
3006 }
3007
3008 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
3014 let record = self.known_branches().get(branch).cloned();
3015 if record.is_none_or(|record| record.kind != "split") {
3016 logdim!("kept {branch} because no split branch record proves ownership");
3017 return false;
3018 }
3019 let local_ref = format!("refs/heads/{branch}");
3020 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
3021 Ok(value) => value.trim().to_string(),
3022 Err(error) => {
3023 logdim!(
3024 "kept {branch} because its tip could not be checked: {}",
3025 error.last_line()
3026 );
3027 return false;
3028 }
3029 };
3030 if expected != disposable_head {
3031 logdim!("kept {branch} because it moved beyond the disposable slice");
3032 return false;
3033 }
3034 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
3035 Ok(true) => {}
3036 Ok(false) => {
3037 logdim!(
3038 "kept {branch} because its reflog contains work outside the disposable slice"
3039 );
3040 return false;
3041 }
3042 Err(error) => {
3043 logdim!(
3044 "kept {branch} because its reflog could not be checked: {}",
3045 error.last_line()
3046 );
3047 return false;
3048 }
3049 }
3050 match self.head_oid_checked(dir) {
3051 Ok(head) if head == disposable_head => {}
3052 Ok(_) => {
3053 logdim!(
3054 "kept {branch} and {} because the worktree moved beyond the disposable slice",
3055 dir.display()
3056 );
3057 return false;
3058 }
3059 Err(error) => {
3060 logdim!(
3061 "kept {branch} and {} because its head could not be checked: {}",
3062 dir.display(),
3063 error.last_line()
3064 );
3065 return false;
3066 }
3067 }
3068 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
3069 Ok(true) => {}
3070 Ok(false) => return false,
3071 Err(error) => {
3072 logdim!(
3073 "kept {branch} and {} because the disposable slice could not be verified: {}",
3074 dir.display(),
3075 error.last_line()
3076 );
3077 return false;
3078 }
3079 }
3080 if let Err(error) =
3081 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
3082 {
3083 logdim!(
3084 "kept {branch} because its exact disposable tip could not be deleted: {}",
3085 error.last_line()
3086 );
3087 return false;
3088 }
3089 match self.exact_ref_exists_checked(&self.root, &local_ref) {
3090 Ok(false) => {
3091 self.forget_branch(branch);
3092 true
3093 }
3094 Ok(true) => {
3095 logdim!("kept {branch} because its ref still exists after deletion");
3096 false
3097 }
3098 Err(error) => {
3099 logdim!(
3100 "kept the branch record for {branch} because deletion could not be verified: {}",
3101 error.last_line()
3102 );
3103 false
3104 }
3105 }
3106 }
3107
3108 pub fn release_review_worktree(&self, number: i64) {
3109 let path = self.worktree_path(&format!("review-{number}"));
3110 match self.review_ref_deletion_is_safe(number) {
3111 Ok(true) => {}
3112 Ok(false) => {
3113 logdim!(
3114 "kept {} because no surviving ref preserves its review history",
3115 path.display()
3116 );
3117 return;
3118 }
3119 Err(error) => {
3120 logdim!(
3121 "kept {} because review history could not be verified: {}",
3122 path.display(),
3123 error.last_line()
3124 );
3125 return;
3126 }
3127 }
3128 match self.remove_worktree_at(&path) {
3129 Ok(true) => match self.delete_review_ref_if_safe(number) {
3130 Ok(true) => {}
3131 Ok(false) => logdim!(
3132 "kept {} because its review history changed before deletion",
3133 review_ref(number)
3134 ),
3135 Err(error) => logdim!(
3136 "kept {} because deletion safety could not be rechecked: {}",
3137 review_ref(number),
3138 error.last_line()
3139 ),
3140 },
3141 Ok(false) => {}
3142 Err(error) => logdim!(
3143 "kept {} because removal did not reach a confirmed quiet point: {}",
3144 path.display(),
3145 error.last_line()
3146 ),
3147 }
3148 }
3149
3150 pub(crate) fn release_review_worktree_checked(
3153 &self,
3154 number: i64,
3155 checkpoint: &WorktreeCheckpoint,
3156 ) -> Result<()> {
3157 let path = self.worktree_path(&format!("review-{number}"));
3158 self.require_unchanged_worktree(
3159 &path,
3160 checkpoint,
3161 &format!("review worktree for PR #{number}"),
3162 )?;
3163 if !self.review_ref_deletion_is_safe(number)? {
3164 bail!(
3165 "the review reference for PR #{number} has reflog-only recovery history. The \
3166 worktree and reference were kept."
3167 );
3168 }
3169 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3170 bail!(
3171 "the verified review worktree at {} could not be removed, so its reference was \
3172 kept",
3173 path.display()
3174 );
3175 }
3176 if !self.delete_review_ref_if_safe(number)? {
3177 bail!(
3178 "the review reference for PR #{number} changed before deletion. The reference was \
3179 kept."
3180 );
3181 }
3182 Ok(())
3183 }
3184
3185 pub fn release_pr_worktree(&self, number: i64) -> bool {
3186 let path = self.worktree_path(&format!("pr-{number}"));
3187 let local = self.branch_for_pr(number);
3188 match self.branch_deletion_is_safe(&local) {
3189 Ok(true) => {}
3190 Ok(false) => {
3191 logdim!(
3192 "kept {local} and {} because no surviving ref preserves its tip",
3193 path.display()
3194 );
3195 return false;
3196 }
3197 Err(error) => {
3198 logdim!(
3199 "kept {local} and {} because preservation could not be verified: {}",
3200 path.display(),
3201 error.last_line()
3202 );
3203 return false;
3204 }
3205 }
3206 match self.remove_worktree_at(&path) {
3207 Ok(true) => match self.delete_branch_if_safe(&local) {
3208 Ok(true) => {
3209 self.forget_branch(&local);
3210 true
3211 }
3212 Ok(false) => {
3213 logdim!("kept {local} because its tip or reflog changed before deletion");
3214 false
3215 }
3216 Err(error) => {
3217 logdim!(
3218 "kept {local} because deletion safety could not be rechecked: {}",
3219 error.last_line()
3220 );
3221 false
3222 }
3223 },
3224 Ok(false) => false,
3225 Err(error) => {
3226 logdim!(
3227 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3228 path.display(),
3229 error.last_line()
3230 );
3231 false
3232 }
3233 }
3234 }
3235
3236 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3247 let remote = format!("origin/{base}");
3248 if self.rev_exists(cwd, &remote) {
3249 return remote;
3250 }
3251 if self.rev_exists(cwd, base) {
3252 logdim!("origin/{base} does not resolve, comparing against local {base}");
3253 return base.to_string();
3254 }
3255 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3256 remote
3257 }
3258
3259 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3260 let spec = format!("{refname}^{{commit}}");
3261 !self
3262 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3263 .trim()
3264 .is_empty()
3265 }
3266
3267 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3268 let range = format!("{}..HEAD", self.base_ref(cwd, base));
3269 !self
3270 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3271 .trim()
3272 .is_empty()
3273 }
3274
3275 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3276 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3277 Ok(found.lines().any(|line| line.trim() == refname))
3278 }
3279
3280 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3281 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3282 count.trim().parse::<usize>().map_err(|e| {
3283 spar_err!(
3284 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3285 count.trim()
3286 )
3287 })
3288 }
3289
3290 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3291 let remote = format!("refs/remotes/origin/{base}");
3292 if self.exact_ref_exists_checked(cwd, &remote)? {
3293 return Ok(remote);
3294 }
3295 let local = format!("refs/heads/{base}");
3296 if self.exact_ref_exists_checked(cwd, &local)? {
3297 return Ok(local);
3298 }
3299 bail!("neither origin/{base} nor local branch {base} resolves")
3300 }
3301
3302 pub(crate) fn commit_count_checked(
3303 &self,
3304 cwd: &Path,
3305 refname: &str,
3306 base: &str,
3307 ) -> Result<usize> {
3308 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3309 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3310 count.trim().parse::<usize>().map_err(|e| {
3311 spar_err!(
3312 "git returned an invalid commit count for {range}: {:?} ({e})",
3313 count.trim()
3314 )
3315 })
3316 }
3317
3318 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3319 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3320 }
3321
3322 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3323 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3324 let head = head.trim().to_string();
3325 if head.is_empty() {
3326 bail!("git returned an empty HEAD for {}", cwd.display());
3327 }
3328 Ok(head)
3329 }
3330
3331 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3336 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3337 self.local_branch_is_preserved(branch.trim())
3338 }
3339
3340 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3343 let known = self.known_branches();
3344 let Some(record) = known.get(branch) else {
3345 return Ok(false);
3346 };
3347 self.branch_is_preserved_checked(branch, record)
3348 }
3349
3350 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3352 has_uncommitted_work(cwd)
3353 }
3354
3355 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3358 repository_has_recoverable_work_with(cwd, true, disposable)
3359 }
3360
3361 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3363 let attributes = attribute_state(cwd)?;
3364 Ok(WorktreeBaseline {
3365 attributes,
3366 ignored_untracked: ignored_untracked_state(cwd)?,
3367 git_state: safe_git_state(cwd)?,
3368 })
3369 }
3370
3371 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3374 let attributes = attribute_state(cwd)?;
3375 Ok(WorktreeCheckpoint {
3376 path: std::fs::canonicalize(cwd)
3377 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3378 attributes,
3379 git_state: safe_git_state(cwd)?,
3380 ignored_untracked: ignored_untracked_state(cwd)?,
3381 })
3382 }
3383
3384 pub(crate) fn set_aside_review_scratch(
3400 &self,
3401 cwd: &Path,
3402 checkpoint: &WorktreeCheckpoint,
3403 number: i64,
3404 ) {
3405 let Ok(after) = probe_again_on_failure(|| ignored_untracked_state(cwd)) else {
3406 return;
3407 };
3408 let created: Vec<PathBuf> = checkpoint
3409 .ignored_untracked
3410 .new_ordinary_paths(&after)
3411 .into_iter()
3412 .filter(|path| {
3413 std::fs::symlink_metadata(cwd.join(path))
3414 .is_ok_and(|meta| meta.is_file() || meta.file_type().is_symlink())
3415 })
3416 .collect();
3417 if created.is_empty() {
3418 return;
3419 }
3420 let shelf = match self.scratch_shelf(number) {
3421 Ok(shelf) => shelf,
3422 Err(e) => {
3423 logdim!("could not make room to move a reviewer's files aside: {e}");
3424 return;
3425 }
3426 };
3427 let mut moved = Vec::new();
3428 for path in created {
3429 let to = shelf.join(&path);
3430 let result = to
3431 .parent()
3432 .map_or(Ok(()), std::fs::create_dir_all)
3433 .and_then(|()| std::fs::rename(cwd.join(&path), &to));
3434 match result {
3435 Ok(()) => moved.push(path),
3436 Err(e) => logdim!(
3437 "could not move {} out of the review checkout: {e}",
3438 cwd.join(&path).display()
3439 ),
3440 }
3441 }
3442 if !moved.is_empty() {
3443 logwarn!(
3444 "PR #{number}: a reviewer left {} file(s) in the review checkout: {}. They were \
3445 moved to {} and the review went on.",
3446 moved.len(),
3447 list_paths(&moved),
3448 shelf.display()
3449 );
3450 }
3451 }
3452
3453 fn scratch_shelf(&self, number: i64) -> Result<PathBuf> {
3456 let scratch = self.root.join(STATE_DIR).join("scratch");
3457 std::fs::create_dir_all(&scratch)
3458 .map_err(|e| spar_err!("could not create {}: {e}", scratch.display()))?;
3459 let stem = format!("pr-{number}-{}", crate::clock::now_secs());
3460 for serial in 0..1000 {
3461 let name = match serial {
3462 0 => stem.clone(),
3463 n => format!("{stem}-{n}"),
3464 };
3465 let shelf = scratch.join(name);
3466 match std::fs::create_dir(&shelf) {
3467 Ok(()) => return Ok(shelf),
3468 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
3469 Err(e) => return Err(spar_err!("could not create {}: {e}", shelf.display())),
3470 }
3471 }
3472 bail!(
3473 "could not choose a free directory under {}",
3474 scratch.display()
3475 )
3476 }
3477
3478 pub(crate) fn require_unchanged_worktree(
3481 &self,
3482 cwd: &Path,
3483 checkpoint: &WorktreeCheckpoint,
3484 label: &str,
3485 ) -> Result<()> {
3486 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3487 crate::error::SparError::uncertain_write(format!(
3488 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3489 cwd.display()
3490 ))
3491 })?;
3492 if resolved != checkpoint.path {
3493 return Err(uncertain_worktree_change(
3494 cwd,
3495 format!(
3496 "the {label} moved from {} to {} during inspection. It was kept.",
3497 checkpoint.path.display(),
3498 resolved.display()
3499 ),
3500 ));
3501 }
3502 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3503 uncertain_worktree_change(
3504 cwd,
3505 format!(
3506 "could not verify attribute files in the {label} at {}: {}. It was kept.",
3507 cwd.display(),
3508 e.last_line()
3509 ),
3510 )
3511 })?;
3512 if attributes != checkpoint.attributes {
3513 return Err(uncertain_worktree_change(
3514 cwd,
3515 format!(
3516 "attribute files in the {label} at {} changed during inspection. It was \
3517 kept for recovery.",
3518 cwd.display()
3519 ),
3520 ));
3521 }
3522 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3523 uncertain_worktree_change(
3524 cwd,
3525 format!(
3526 "could not verify the Git state of the {label} at {}: {}. It was kept.",
3527 cwd.display(),
3528 e.last_line()
3529 ),
3530 )
3531 })?;
3532 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3533 uncertain_worktree_change(
3534 cwd,
3535 format!(
3536 "could not verify untracked files in the {label} at {}: {}. It was kept.",
3537 cwd.display(),
3538 e.last_line()
3539 ),
3540 )
3541 })?;
3542 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3543 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3547 let tracked = checkpoint.git_state.describe_difference(&git_state);
3548 let untracked = (!leavings.condemning.is_empty()).then(|| {
3549 format!(
3550 "untracked file(s) changed: {}",
3551 list_paths(&leavings.condemning)
3552 )
3553 });
3554 let Some(reason) = join_reasons(tracked, untracked) else {
3555 return Ok(());
3556 };
3557 Err(uncertain_worktree_change(
3558 cwd,
3559 format!(
3560 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3561 for recovery.",
3562 cwd.display()
3563 ),
3564 ))
3565 }
3566
3567 pub(crate) fn refuse_new_ignored_files(
3571 &self,
3572 cwd: &Path,
3573 baseline: &WorktreeBaseline,
3574 ) -> Result<()> {
3575 self.allow_ignored_files(cwd, baseline).map(drop)
3576 }
3577
3578 pub(crate) fn refuse_failed_edit_leavings(
3580 &self,
3581 cwd: &Path,
3582 baseline: &WorktreeBaseline,
3583 ) -> Result<()> {
3584 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3585 .map(drop)
3586 }
3587
3588 pub(crate) fn refuse_read_only_leavings(
3590 &self,
3591 cwd: &Path,
3592 baseline: &WorktreeBaseline,
3593 ) -> Result<()> {
3594 self.refuse_changed_attributes(cwd, baseline)?;
3595 let after = ignored_untracked_state(cwd).map_err(|e| {
3596 uncertain_worktree_change(
3597 cwd,
3598 format!(
3599 "could not verify untracked files in {} after a read-only call: {}. The \
3600 worktree was kept for recovery.",
3601 cwd.display(),
3602 e.last_line()
3603 ),
3604 )
3605 })?;
3606 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3607 if leavings.condemning.is_empty() {
3608 return Ok(());
3609 }
3610 Err(uncertain_worktree_change(
3611 cwd,
3612 format!(
3613 "the read-only call left untracked file(s) in {} that the project does not \
3614 ignore: {}. The worktree was kept for recovery.",
3615 cwd.display(),
3616 list_paths(&leavings.condemning)
3617 ),
3618 ))
3619 }
3620
3621 fn allow_ignored_files(
3624 &self,
3625 cwd: &Path,
3626 baseline: &WorktreeBaseline,
3627 ) -> Result<AllowedArtifacts> {
3628 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3629 }
3630
3631 fn check_new_ignored_files(
3632 &self,
3633 cwd: &Path,
3634 baseline: &WorktreeBaseline,
3635 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3636 ) -> Result<AllowedArtifacts> {
3637 self.refuse_changed_attributes(cwd, baseline)?;
3638 let after = ignored_untracked_state(cwd).map_err(|e| {
3639 uncertain_worktree_change(
3640 cwd,
3641 format!(
3642 "could not verify untracked files in {} after editing: {}. The worktree was \
3643 kept for recovery.",
3644 cwd.display(),
3645 e.last_line()
3646 ),
3647 )
3648 })?;
3649 let changed = baseline.ignored_untracked.changed_paths(&after);
3650 if changed.is_empty() {
3651 return Ok(AllowedArtifacts::default());
3652 }
3653 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3654 .into_iter()
3655 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3656 if changed.is_empty() {
3657 return Ok(AllowedArtifacts::split(ignored, &after));
3658 }
3659 let listed = list_paths(&changed);
3660 Err(uncertain_worktree_change(
3661 cwd,
3662 format!(
3663 "the editing call created or changed untracked or ignored file(s) in {} that \
3664 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3665 recovery.",
3666 cwd.display()
3667 ),
3668 ))
3669 }
3670
3671 pub(crate) fn refuse_changed_existing_untracked(
3676 &self,
3677 cwd: &Path,
3678 baseline: &WorktreeBaseline,
3679 ) -> Result<()> {
3680 self.check_changed_existing_untracked(cwd, baseline)
3681 .map(drop)
3682 }
3683
3684 fn allow_changed_ignored_artifacts(
3685 &self,
3686 cwd: &Path,
3687 baseline: &WorktreeBaseline,
3688 ) -> Result<AllowedArtifacts> {
3689 self.check_changed_existing_untracked(cwd, baseline)
3690 }
3691
3692 fn check_changed_existing_untracked(
3693 &self,
3694 cwd: &Path,
3695 baseline: &WorktreeBaseline,
3696 ) -> Result<AllowedArtifacts> {
3697 self.refuse_changed_attributes(cwd, baseline)?;
3698 let after = ignored_untracked_state(cwd).map_err(|e| {
3699 uncertain_worktree_change(
3700 cwd,
3701 format!(
3702 "could not verify existing untracked files in {} after editing: {}. The \
3703 worktree was kept for recovery.",
3704 cwd.display(),
3705 e.last_line()
3706 ),
3707 )
3708 })?;
3709 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3710 if changed.is_empty() {
3711 return Ok(AllowedArtifacts::default());
3712 }
3713 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3714 .into_iter()
3715 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3716 if changed.is_empty() {
3717 return Ok(AllowedArtifacts::split(ignored, &after));
3718 }
3719 let listed = list_paths(&changed);
3720 Err(uncertain_worktree_change(
3721 cwd,
3722 format!(
3723 "the editing call changed or deleted existing untracked file(s) in {}: \
3724 {listed}. The worktree was kept for recovery.",
3725 cwd.display()
3726 ),
3727 ))
3728 }
3729
3730 pub(crate) fn refuse_unrepresented_tracked_changes(
3737 &self,
3738 cwd: &Path,
3739 baseline: &WorktreeBaseline,
3740 ) -> Result<()> {
3741 self.refuse_changed_attributes(cwd, baseline)?;
3742 let after = safe_git_state(cwd).map_err(|e| {
3743 uncertain_worktree_change(
3744 cwd,
3745 format!(
3746 "could not verify tracked files in {} after editing: {}. The worktree was \
3747 kept for recovery.",
3748 cwd.display(),
3749 e.last_line()
3750 ),
3751 )
3752 })?;
3753 let mut changed = Vec::new();
3754 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3755 uncertain_worktree_change(
3756 cwd,
3757 format!(
3758 "could not record untracked files before verifying transformed content in {}: \
3759 {}. The worktree was kept for recovery.",
3760 cwd.display(),
3761 e.last_line()
3762 ),
3763 )
3764 })?;
3765 let mut filter_was_run = false;
3766 let mut filter_problem = None;
3767 let mut repositories: BTreeSet<PathBuf> =
3768 baseline.git_state.repositories.keys().cloned().collect();
3769 repositories.extend(after.repositories.keys().cloned());
3770 'repositories: for repository_path in repositories {
3771 let before_repository = baseline.git_state.repositories.get(&repository_path);
3772 let after_repository = after.repositories.get(&repository_path);
3773 if before_repository.is_none() || after_repository.is_none() {
3774 changed.push(repository_path.clone());
3775 continue;
3776 }
3777 if before_repository.map(|repository| &repository.gitlinks)
3778 != after_repository.map(|repository| &repository.gitlinks)
3779 {
3780 changed.push(repository_path.join("<gitlinks>"));
3781 }
3782 let mut paths = BTreeSet::new();
3783 if let Some(repository) = before_repository {
3784 paths.extend(repository.tracked.keys().cloned());
3785 }
3786 if let Some(repository) = after_repository {
3787 paths.extend(repository.tracked.keys().cloned());
3788 }
3789 for path in paths {
3790 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3791 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3792 let worktree_changed =
3793 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3794 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3795 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3796 if !worktree_changed {
3797 continue;
3798 }
3799 if !index_changed {
3800 changed.push(repository_path.join(&path));
3801 continue;
3802 }
3803 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3804 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3805 let Some(current_entry) = current else {
3806 continue;
3807 };
3808 let Some(current_worktree) = current_worktree else {
3809 continue;
3810 };
3811 let content_changed =
3812 before_worktree.map(|file| file.content) != Some(current_worktree.content);
3813 let mode_changed = before_worktree.map(|file| file.mode.as_str())
3814 != Some(current_worktree.mode.as_str());
3815 let repository = cwd.join(&repository_path);
3816 let represented_content = if content_changed {
3817 filter_was_run = true;
3818 let result =
3819 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
3820 self.refuse_changed_attributes(cwd, baseline)?;
3821 match result {
3822 Ok(expected) => expected == current_worktree.content,
3823 Err(error) => {
3824 filter_problem = Some(format!(
3825 "could not verify transformed content for {:?}: {}",
3826 repository_path.join(&path),
3827 error.last_line()
3828 ));
3829 false
3830 }
3831 }
3832 } else {
3833 true
3834 };
3835 let represented_mode =
3836 !mode_changed || current_worktree.mode == current_entry.index_mode;
3837 if !represented_content || !represented_mode {
3838 changed.push(repository_path.join(&path));
3839 }
3840 if filter_problem.is_some() {
3841 break 'repositories;
3842 }
3843 }
3844 }
3845 if filter_was_run {
3846 self.refuse_changed_attributes(cwd, baseline)?;
3847 let verified = safe_git_state(cwd).map_err(|e| {
3848 uncertain_worktree_change(
3849 cwd,
3850 format!(
3851 "could not recheck tracked files after verifying transformed content in \
3852 {}: {}. The worktree was kept for recovery.",
3853 cwd.display(),
3854 e.last_line()
3855 ),
3856 )
3857 })?;
3858 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3859 uncertain_worktree_change(
3860 cwd,
3861 format!(
3862 "could not recheck untracked files after verifying transformed content \
3863 in {}: {}. The worktree was kept for recovery.",
3864 cwd.display(),
3865 e.last_line()
3866 ),
3867 )
3868 })?;
3869 if verified != after
3870 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3871 {
3872 return Err(uncertain_worktree_change(
3873 cwd,
3874 "a content filter changed the worktree while SPAR verified the managed \
3875 commit. The worktree was kept for recovery.",
3876 ));
3877 }
3878 self.refuse_changed_existing_untracked(cwd, baseline)?;
3879 }
3880 if let Some(problem) = filter_problem {
3881 return Err(uncertain_worktree_change(
3882 cwd,
3883 format!("{problem}. The worktree was kept for recovery."),
3884 ));
3885 }
3886 if changed.is_empty() {
3887 return Ok(());
3888 }
3889 let listed = list_paths(&changed);
3890 Err(uncertain_worktree_change(
3891 cwd,
3892 format!(
3893 "the editing call changed tracked working-file bytes, modes, repositories, or \
3894 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3895 recovery."
3896 ),
3897 ))
3898 }
3899
3900 pub(crate) fn refuse_changed_attributes(
3901 &self,
3902 cwd: &Path,
3903 baseline: &WorktreeBaseline,
3904 ) -> Result<()> {
3905 let after = attribute_state(cwd).map_err(|e| {
3906 uncertain_worktree_change(
3907 cwd,
3908 format!(
3909 "could not verify attribute files in {} after editing: {}. The worktree was \
3910 kept for recovery.",
3911 cwd.display(),
3912 e.last_line()
3913 ),
3914 )
3915 })?;
3916 if after == baseline.attributes {
3917 return Ok(());
3918 }
3919 Err(uncertain_worktree_change(
3920 cwd,
3921 format!(
3922 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3923 refused to run a Git operation that could select a new external filter.",
3924 cwd.display()
3925 ),
3926 ))
3927 }
3928
3929 pub(crate) fn commit_pending_changes(
3934 &self,
3935 cwd: &Path,
3936 baseline: &WorktreeBaseline,
3937 preferred_subject: &str,
3938 fallback_subject: &str,
3939 ) -> Result<bool> {
3940 let mut artifacts = IgnoredArtifacts::default();
3941 self.refuse_changed_attributes(cwd, baseline)?;
3942 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3943 refuse_unsafe_index_flags(cwd)?;
3944 if !self.has_uncommitted_changes(cwd)? {
3945 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3946 artifacts.report(cwd);
3947 return Ok(false);
3948 }
3949 self.stage_managed_changes(cwd, baseline).map_err(|e| {
3950 e.with_message(format!(
3951 "could not stage changes in {}: {}",
3952 cwd.display(),
3953 e.last_line()
3954 ))
3955 })?;
3956 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3959 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3960 if !changed_gitlinks.is_empty() {
3961 let listed = list_paths(&changed_gitlinks);
3962 bail!(
3963 "the editing call added or changed a gitlink at {listed}. It was staged but not \
3964 committed because the referenced repository objects might exist only inside \
3965 this worktree. The worktree was kept for recovery."
3966 );
3967 }
3968 let mut subject = self.clean_title(preferred_subject)?;
3969 if subject.trim().is_empty() {
3970 subject = self.clean_title(fallback_subject)?;
3971 }
3972 self.commit_staged_changes(cwd, &subject).map_err(|e| {
3973 e.with_message(format!(
3974 "could not commit changes in {}: {}. The staged files were kept.",
3975 cwd.display(),
3976 e.last_line()
3977 ))
3978 })?;
3979 if has_tracked_or_staged_work(cwd)? {
3980 bail!(
3981 "the commit in {} left additional uncommitted files. They were kept for \
3982 recovery.",
3983 cwd.display()
3984 );
3985 }
3986 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3987 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3988 artifacts.report(cwd);
3989 Ok(true)
3990 }
3991
3992 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3993 let after = ignored_untracked_state(cwd)?;
3994 self.git_at_without_automation(cwd, &["add", "-u"])?;
3995 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3996 if paths.is_empty() {
3997 return Ok(());
3998 }
3999 let mut input = Vec::new();
4000 for path in paths {
4001 input.extend(os_str_bytes(path.as_os_str())?);
4002 input.push(0);
4003 }
4004 let argv = git_without_automation_argv(&[
4005 "--literal-pathspecs",
4006 "add",
4007 "--pathspec-from-file=-",
4008 "--pathspec-file-nul",
4009 ]);
4010 proc::run_with_input_bytes(
4011 &argv,
4012 &self.git_opts(Some(cwd), true).stop_descendants(true),
4013 &input,
4014 )?;
4015 Ok(())
4016 }
4017
4018 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
4021 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
4022 .map(|_| ())
4023 }
4024
4025 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
4032 let range = format!("{}..{refname}", self.base_ref(cwd, base));
4033 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
4034 .trim()
4035 .parse()
4036 .unwrap_or(0)
4037 }
4038
4039 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4043 let range = format!("{}..{refname}", self.base_ref(cwd, base));
4044 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4045 .lines()
4046 .map(str::to_string)
4047 .collect()
4048 }
4049
4050 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
4065 let ancestor = self
4066 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
4067 .is_ok();
4068 if !ancestor {
4069 return None;
4070 }
4071 let range = format!("{earlier}..{later}");
4072 Some(
4073 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4074 .lines()
4075 .map(str::to_string)
4076 .collect(),
4077 )
4078 }
4079
4080 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4083 let range = format!("{}..{refname}", self.base_ref(cwd, base));
4084 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
4085 .lines()
4086 .map(str::trim)
4087 .filter(|line| !line.is_empty())
4088 .map(str::to_string)
4089 .collect()
4090 }
4091
4092 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
4108 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4109 self.git_try_at(
4110 Some(cwd),
4111 &["diff", "--name-only", "--no-renames", "-z", &range],
4112 )
4113 .split('\0')
4114 .filter(|path| !path.is_empty())
4115 .map(str::to_string)
4116 .collect()
4117 }
4118
4119 pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
4134 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4135 let argv = git_without_maintenance_argv(&[
4136 "-c",
4137 "core.quotePath=false",
4138 "diff",
4139 "--no-color",
4140 "--no-ext-diff",
4141 "--no-textconv",
4142 "--unified=3",
4143 "--inter-hunk-context=0",
4144 "--diff-algorithm=myers",
4145 "--indent-heuristic",
4146 "--submodule=short",
4147 "--find-renames",
4148 "--src-prefix=a/",
4149 "--dst-prefix=b/",
4150 &range,
4151 ]);
4152 let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
4153 new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
4154 }
4155
4156 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
4159 self.git_try_at(
4160 Some(cwd),
4161 &[
4162 "diff",
4163 "--name-only",
4164 "--no-renames",
4165 "-z",
4166 &format!("{from}..{to}"),
4167 ],
4168 )
4169 .split('\0')
4170 .filter(|path| !path.is_empty())
4171 .map(str::to_string)
4172 .collect()
4173 }
4174
4175 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
4178 let base_ref = self.base_ref(cwd, base);
4179 let out = self
4180 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
4181 .map_err(|e| {
4182 spar_err!(
4183 "could not find where {refname} and {base_ref} diverged. {}",
4184 e.last_line()
4185 )
4186 })?;
4187 let sha = out.trim().to_string();
4188 if sha.is_empty() {
4189 bail!("{refname} and {base_ref} share no history");
4190 }
4191 Ok(sha)
4192 }
4193
4194 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
4195 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4196 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
4197 full.trim().to_string()
4198 }
4199
4200 pub fn rewrite_commits_if_needed(
4213 &self,
4214 cwd: &Path,
4215 base: &str,
4216 floor: Option<&str>,
4217 ) -> Result<()> {
4218 let base_ref = self.base_ref(cwd, base);
4219 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
4220 self.report_commits_left_alone(cwd, &base_ref, floor);
4221
4222 let range = format!("{floor}..HEAD");
4223 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
4224
4225 let offenders = raw
4226 .split('\x1e')
4227 .filter_map(|entry| entry.split_once('\0'))
4228 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4229 .count();
4230 if offenders == 0 {
4231 return Ok(());
4232 }
4233 logdim!("{offenders} commit message(s) violated style rules, rewriting");
4234
4235 let exe = self_binary()?;
4236 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
4237
4238 let argv: Vec<String> = [
4239 "git",
4240 "filter-branch",
4241 "-f",
4242 "--msg-filter",
4243 &filter,
4244 &range,
4245 ]
4246 .iter()
4247 .map(|s| s.to_string())
4248 .collect();
4249 let opts = ExecOpts::new()
4250 .cwd(cwd)
4251 .check(false)
4252 .timeout_secs(600)
4253 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4254 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4255 .env(
4256 "SPAR_BAN_AI_ATTRIBUTION",
4257 bool_env(self.style.ban_ai_attribution),
4258 );
4259 let _ = proc::run(&argv, &opts);
4260
4261 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4262 if !style::violations(&after, &self.style).is_empty() {
4263 bail!(
4264 "commit messages still violate style rules after a rewrite in {}.",
4265 cwd.display()
4266 );
4267 }
4268 Ok(())
4269 }
4270
4271 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4276 if floor == base_ref {
4277 return;
4278 }
4279 let raw = self.git_try_at(
4280 Some(cwd),
4281 &[
4282 "log",
4283 &format!("{base_ref}..{floor}"),
4284 "--format=%H%x00%B%x1e",
4285 ],
4286 );
4287 let left: Vec<&str> = raw
4288 .split('\x1e')
4289 .filter_map(|entry| entry.split_once('\0'))
4290 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4291 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4292 .collect();
4293 if left.is_empty() {
4294 return;
4295 }
4296 logdim!(
4297 "{} commit message(s) already on the branch break the style rules and were left \
4298 alone: {}. spar does not rewrite commits it did not write.",
4299 left.len(),
4300 left.join(", ")
4301 );
4302 }
4303
4304 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4319 self.push_to(cwd, &PushTarget::Origin, branch, expected)
4320 }
4321
4322 pub fn push_to(
4324 &self,
4325 cwd: &Path,
4326 target: &PushTarget,
4327 branch: &str,
4328 expected: Option<&str>,
4329 ) -> Result<()> {
4330 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4331 if let Some(head) = expected {
4332 self.refuse_push_that_drops(cwd, head)?;
4333 }
4334 let pushed = self
4335 .git_push(cwd, target, branch, expected)
4336 .map_err(|e| push_error(target, branch, &e));
4337 self.record_write(pushed)
4338 }
4339
4340 pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4352 let origin = PushTarget::Origin;
4353 let refused = match self.git_push(cwd, &origin, branch, None) {
4354 Ok(()) => return self.record_write(Ok(origin)),
4355 Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4356 Err(e) => {
4357 let err = push_error(&origin, branch, &e);
4358 let err = if push_denial(&e.to_string()).is_some() {
4359 spar_err!(
4360 "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4361 instead."
4362 )
4363 } else {
4364 err
4365 };
4366 return self.record_write(Err(err));
4367 }
4368 };
4369 self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4370 }
4371
4372 fn publish_on_fork(
4373 &self,
4374 cwd: &Path,
4375 branch: &str,
4376 base: &str,
4377 refused: &crate::error::SparError,
4378 ) -> Result<PushTarget> {
4379 let fork = self.fork().map_err(|e| {
4380 spar_err!(
4381 "{} spar could not publish it from a fork instead: {}",
4382 push_error(&PushTarget::Origin, branch, refused),
4383 e.last_line()
4384 )
4385 })?;
4386 let lease = self.fork_branch_lease(&fork, branch, base)?;
4387 let refused = refused.to_string();
4388 log!(
4389 "{} Publishing {branch} from the fork {} instead.",
4390 push_denial(&refused).unwrap_or("origin refused the push."),
4391 fork.full_name
4392 );
4393 let target = PushTarget::Fork(fork);
4394 self.git_push(cwd, &target, branch, lease.as_deref())
4397 .map_err(|e| push_error(&target, branch, &e))?;
4398 Ok(target)
4399 }
4400
4401 fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4409 let live_ref = format!("refs/heads/{branch}");
4410 let unreadable = |e: crate::error::SparError| {
4411 spar_err!(
4412 "could not check whether the fork {} already has {branch}. {}",
4413 fork.full_name,
4414 e.last_line()
4415 )
4416 };
4417 let listed = self
4418 .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4419 .map_err(unreadable)?;
4420 let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4421 return Ok(None);
4422 };
4423 let local = format!("refs/spar/fork/{branch}");
4424 self.git(&[
4425 "fetch",
4426 "--no-tags",
4427 &fork.url,
4428 &format!("+{live_ref}:{local}"),
4429 ])
4430 .map_err(unreadable)?;
4431 let ahead = self.commit_count_checked(&self.root, &local, base);
4432 let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4433 self.git_try(&["update-ref", "-d", &local]);
4434 let ahead = ahead?;
4435 if !held {
4436 bail!(
4437 "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4438 and no pull request accounts for them. Publishing over it would lose that work.\n\
4439 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4440 run spar again.",
4441 fork.full_name,
4442 fork.url
4443 );
4444 }
4445 Ok(Some(tip))
4446 }
4447
4448 fn git_push(
4449 &self,
4450 cwd: &Path,
4451 target: &PushTarget,
4452 branch: &str,
4453 expected: Option<&str>,
4454 ) -> Result<()> {
4455 let refspec = format!("HEAD:refs/heads/{branch}");
4456 let lease = match (expected, target) {
4457 (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4458 (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4462 (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4463 };
4464 self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4465 .map(|_| ())
4466 }
4467
4468 fn fork(&self) -> Result<Fork> {
4475 match self.existing_fork() {
4476 Ok(Some(fork)) => return Ok(fork),
4477 Ok(None) => {}
4478 Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4479 }
4480 let answer = self
4481 .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4482 .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4483 let fork = fork_from_api(&answer, &self.origin_push_url())?;
4484 self.wait_for_fork(&fork)?;
4485 Ok(self.fork.get_or_init(|| fork).clone())
4486 }
4487
4488 fn existing_fork(&self) -> Result<Option<Fork>> {
4491 if let Some(fork) = self.fork.get() {
4492 return Ok(Some(fork.clone()));
4493 }
4494 let answer = self
4495 .gh(&[
4496 "api",
4497 "graphql",
4498 "-F",
4499 "owner={owner}",
4500 "-F",
4501 "name={repo}",
4502 "-f",
4503 "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4504 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4505 login } sshUrl url } } } }",
4506 "--jq",
4507 ".data.repository.forks.nodes",
4508 ])
4509 .map_err(|e| {
4510 spar_err!(
4511 "could not look for a fork of this repository. {}",
4512 e.last_line()
4513 )
4514 })?;
4515 let found = fork_from_listing(&answer, &self.origin_push_url())?;
4516 Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4517 }
4518
4519 fn origin_refuses_push(&self) -> bool {
4523 self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4524 .trim()
4525 == "false"
4526 }
4527
4528 fn origin_push_url(&self) -> String {
4529 self.git_try(&["remote", "get-url", "--push", "origin"])
4530 }
4531
4532 pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4537 if !pr.is_cross_repository {
4538 return Some(PushTarget::Origin);
4539 }
4540 let viewer = self.viewer_login().ok()?;
4541 own_fork_target(pr, viewer, &self.origin_push_url())
4542 }
4543
4544 fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4545 const TRIES: u32 = 30;
4546 let mut tries = 1;
4547 loop {
4548 match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4549 Ok(_) => return Ok(()),
4550 Err(e) if tries >= TRIES => bail!(
4551 "the fork {} could not be reached after {} seconds. {}",
4552 fork.full_name,
4553 TRIES * 2,
4554 e.last_line()
4555 ),
4556 Err(_) => {
4557 tries += 1;
4558 std::thread::sleep(std::time::Duration::from_secs(2));
4559 }
4560 }
4561 }
4562 }
4563
4564 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4572 let head = self.head_oid_checked(cwd)?;
4573 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4574 return Ok(());
4575 }
4576 bail!(
4577 "refusing to push {head} because it does not contain the reviewed head {expected}. \
4578 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4579 the intended commits on top of it before resuming.",
4580 cwd.display()
4581 )
4582 }
4583
4584 pub fn push_split_branch(
4593 &self,
4594 cwd: &Path,
4595 branch: &str,
4596 ) -> std::result::Result<(), SplitPushError> {
4597 let remote_ref = format!("refs/heads/{branch}");
4598 let lease = format!("--force-with-lease={remote_ref}:");
4599 let refspec = format!("HEAD:{remote_ref}");
4600 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4601 Ok(_) => Ok(()),
4602 Err(push_error) => {
4603 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4604 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4605 reconcile_failed_split_push(branch, push_error, local, remote)
4606 }
4607 };
4608 self.record_write(result)
4609 }
4610
4611 pub fn gh(&self, args: &[&str]) -> Result<String> {
4614 self.gh_at(None, args)
4615 }
4616
4617 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4618 let mut argv = vec!["gh".to_string()];
4619 argv.extend(args.iter().map(|s| s.to_string()));
4620 proc::run(
4621 &argv,
4622 &ExecOpts::new()
4623 .cwd(cwd.unwrap_or(&self.root))
4624 .timeout_secs(300),
4625 )
4626 }
4627
4628 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4634 let mut argv = vec!["gh".to_string()];
4635 argv.extend(args.iter().map(|s| s.to_string()));
4636 proc::run(
4637 &argv,
4638 &ExecOpts::new()
4639 .cwd(&self.root)
4640 .timeout_secs(300)
4641 .stdin(stdin),
4642 )
4643 }
4644
4645 pub fn gh_try(&self, args: &[&str]) -> String {
4646 let mut argv = vec!["gh".to_string()];
4647 argv.extend(args.iter().map(|s| s.to_string()));
4648 proc::run(
4649 &argv,
4650 &ExecOpts::new()
4651 .cwd(&self.root)
4652 .check(false)
4653 .timeout_secs(300),
4654 )
4655 .unwrap_or_default()
4656 }
4657
4658 pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4663 let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4664 for (name, value) in headers {
4665 argv.push("-H".to_string());
4666 argv.push(format!("{name}: {value}"));
4667 }
4668 argv.push(endpoint.to_string());
4669 let out = proc::exec(
4670 &argv,
4671 &ExecOpts::new()
4672 .cwd(&self.root)
4673 .check(false)
4674 .timeout_secs(300),
4675 )?;
4676 parse_api_response(&out.stdout)
4677 .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4678 }
4679
4680 pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4682 let text = self.gh(&[
4683 "pr",
4684 "list",
4685 "--state",
4686 "open",
4687 "--limit",
4688 "500",
4689 "--json",
4690 PR_SWEEP_FIELDS,
4691 ])?;
4692 serde_json::from_str(&text)
4693 .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4694 }
4695
4696 pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4698 let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4699 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4700 }
4701
4702 pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4707 let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4709 if slug.is_empty() || login.is_empty() {
4710 return None;
4711 }
4712 let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4713 let resp = self.gh_api_raw(&path, &[]).ok()?;
4714 match resp.status {
4715 200 => {
4716 let body: Value = serde_json::from_str(&resp.body).ok()?;
4717 Some(body.get("state")?.as_str()? == "active")
4718 }
4719 404 => Some(false),
4720 _ => None,
4721 }
4722 }
4723
4724 pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4731 let text = self
4732 .gh(&[
4733 "api",
4734 "graphql",
4735 "-F",
4736 "owner={owner}",
4737 "-F",
4738 "repo={repo}",
4739 "-F",
4740 &format!("number={number}"),
4741 "-f",
4742 &format!("query={REQUESTS_QUERY}"),
4743 ])
4744 .ok()?;
4745 parse_review_requests(&text)
4746 }
4747
4748 pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4750 if from.is_empty() || to.is_empty() {
4751 return None;
4752 }
4753 let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4754 self.gh_try(&["api", &path, "--jq", ".total_commits"])
4755 .trim()
4756 .parse()
4757 .ok()
4758 }
4759
4760 pub fn repo_name(&self) -> Option<String> {
4762 let name = self.gh_try(&[
4763 "repo",
4764 "view",
4765 "--json",
4766 "nameWithOwner",
4767 "--jq",
4768 ".nameWithOwner",
4769 ]);
4770 let name = name.trim();
4771 (!name.is_empty()).then(|| name.to_string())
4772 }
4773
4774 pub fn listen_state_path(&self) -> PathBuf {
4775 self.root.join(STATE_DIR).join("state").join("listen.json")
4776 }
4777
4778 pub fn listen_lock_path(&self) -> PathBuf {
4779 self.root.join(STATE_DIR).join("state").join("listen.lock")
4780 }
4781
4782 pub fn viewer_login(&self) -> Result<&str> {
4793 if let Some(login) = self.viewer.get() {
4794 return Ok(login);
4795 }
4796 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4797 let login = if !rest.trim().is_empty() {
4798 rest.trim().to_string()
4799 } else {
4800 self.gh(&[
4803 "api",
4804 "graphql",
4805 "-f",
4806 "query={ viewer { login } }",
4807 "--jq",
4808 ".data.viewer.login",
4809 ])
4810 .map_err(|e| {
4811 spar_err!(
4812 "could not find out who `gh` is authenticated as, so spar cannot tell its \
4813 own comments from anybody else's. {}\nRun `gh auth status`.",
4814 e.last_line()
4815 )
4816 })?
4817 .trim()
4818 .to_string()
4819 };
4820 if login.is_empty() {
4821 bail!("`gh` reported an empty login. Run `gh auth status`.");
4822 }
4823 Ok(self.viewer.get_or_init(|| login))
4824 }
4825
4826 pub fn read_issue(&self, number: i64) -> Result<Issue> {
4832 let text = self
4833 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4834 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4835 serde_json::from_str(&text)
4836 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4837 }
4838
4839 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4840 let mut issues = Vec::new();
4841 for number in numbers {
4842 let text = self
4843 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4844 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4845 let issue: Issue = serde_json::from_str(&text)
4846 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4847 if issue.is_closed() {
4848 crate::log!("issue #{number} is closed, skipping");
4849 continue;
4850 }
4851 issues.push(issue);
4852 }
4853 if issues.is_empty() {
4854 bail!("no open issues to work on");
4855 }
4856 Ok(issues)
4857 }
4858
4859 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4865 #[derive(Deserialize)]
4866 struct Row {
4867 number: i64,
4868 }
4869 let text = self.gh(&[
4870 kind,
4871 "list",
4872 "--state",
4873 "open",
4874 "--limit",
4875 &FETCH_CEILING.to_string(),
4876 "--json",
4877 "number",
4878 ])?;
4879 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4880 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4881 numbers.sort_unstable();
4882
4883 let noun = if kind == "issue" { "issues" } else { "PRs" };
4884 let found = numbers.len();
4885 if min_number > 0 {
4886 numbers.retain(|n| *n >= min_number);
4887 let skipped = found - numbers.len();
4888 if skipped > 0 {
4889 crate::log!("{skipped} open {noun} below #{min_number} skipped");
4890 }
4891 }
4892 if found >= FETCH_CEILING {
4893 crate::log!(
4894 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4895 considered."
4896 );
4897 }
4898 if numbers.len() > limit {
4899 crate::log!(
4900 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4901 explicitly.",
4902 numbers.len()
4903 );
4904 numbers.truncate(limit);
4905 }
4906 Ok(numbers)
4907 }
4908
4909 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4911 self.open_numbers("issue", limit, min_number)
4912 }
4913
4914 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4915 self.open_numbers("pr", limit, min_number)
4916 }
4917
4918 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4919 self.branch_prs(branch, "open").into_iter().next()
4920 }
4921
4922 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4925 let text = self.gh(&[
4926 "pr",
4927 "list",
4928 "--head",
4929 branch,
4930 "--base",
4931 base,
4932 "--state",
4933 "open",
4934 "--json",
4935 "number,url,title,baseRefName",
4936 ])?;
4937 pr_for_base(&text, branch, base)
4938 }
4939
4940 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4944 self.branch_prs(branch, "all")
4945 }
4946
4947 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4948 let text = self.gh_try(&[
4949 "pr",
4950 "list",
4951 "--head",
4952 branch,
4953 "--state",
4954 state,
4955 "--json",
4956 "number,url,title",
4957 ]);
4958 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4959 }
4960
4961 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4967 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4968 let text = self
4969 .gh(&[
4970 "api",
4971 &path,
4972 "--jq",
4973 "if .pull_request then \"pr\" else \"issue\" end",
4974 ])
4975 .map_err(|e| {
4976 spar_err!(
4977 "no issue or pull request #{number} in this repository. {}",
4978 e.last_line()
4979 )
4980 })?;
4981 match text.trim() {
4982 "pr" => Ok(ItemKind::Pr),
4983 "issue" => Ok(ItemKind::Issue),
4984 other => Err(spar_err!(
4985 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4986 )),
4987 }
4988 }
4989
4990 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
5001 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
5002 if let Some(pr) = mine {
5003 return Ok(Some(pr));
5004 }
5005 let listed = match self.gh(&[
5006 "pr",
5007 "list",
5008 "--state",
5009 "open",
5010 "--limit",
5011 &FETCH_CEILING.to_string(),
5012 "--json",
5013 "number,url,title,baseRefName,closingIssuesReferences",
5014 ]) {
5015 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
5016 spar_err!("could not read the pull request list while looking for #{issue}")
5017 })?,
5018 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
5019 };
5020 if listed.len() >= FETCH_CEILING {
5021 logwarn!(
5022 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
5023 );
5024 }
5025 let default = self.default_branch(base);
5026 if !base.eq_ignore_ascii_case(&default) {
5027 logwarn!(
5031 "#{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."
5032 );
5033 }
5034 Ok(pick_linked_pr(&listed, issue))
5035 }
5036
5037 fn pr_for_branch_failing_closed(
5039 &self,
5040 branch: &str,
5041 base: &str,
5042 issue: i64,
5043 ) -> Result<Option<PrRef>> {
5044 match self.try_pr_for_branch(branch, base) {
5045 Ok(found) => Ok(found),
5046 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
5047 }
5048 }
5049
5050 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
5059 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
5060 return Err(spar_err!(
5061 "could not check whether a pull request already exists for #{issue}: {}. \
5062 Refusing to implement over the top of work that may already be open.",
5063 e.last_line()
5064 ));
5065 }
5066 logwarn!(
5067 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
5068 whether a pull request already exists for it.",
5069 e.last_line()
5070 );
5071 Ok(())
5072 }
5073
5074 pub fn pr_view(&self, number: i64) -> Result<PrView> {
5075 let text = self.gh(&[
5076 "pr",
5077 "view",
5078 &number.to_string(),
5079 "--json",
5080 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
5081 isCrossRepository,headRepositoryOwner,headRepository",
5082 ])?;
5083 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
5084 }
5085
5086 fn try_pr_state(&self, number: i64) -> Result<String> {
5087 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
5088 serde_json::from_str::<Value>(text.trim())
5089 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
5090 .get("state")
5091 .and_then(Value::as_str)
5092 .map(str::to_string)
5093 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
5094 }
5095
5096 pub fn pr_state(&self, number: i64) -> String {
5097 self.try_pr_state(number).unwrap_or_default()
5098 }
5099
5100 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
5102 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
5103 let oid = serde_json::from_str::<Value>(&text)
5104 .ok()
5105 .and_then(|value| {
5106 value
5107 .get("headRefOid")
5108 .and_then(Value::as_str)
5109 .map(str::trim)
5110 .filter(|oid| !oid.is_empty())
5111 .map(str::to_string)
5112 })
5113 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
5114 Ok(oid)
5115 }
5116
5117 pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
5121 let pull = self.pr_head_oid(pr.number)?;
5122 let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
5123 None
5124 } else {
5125 let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
5126 let listed = self
5127 .git(&["ls-remote", "--heads", "origin", &remote_ref])
5128 .map_err(|e| {
5129 spar_err!(
5130 "could not read where {} is on origin: {}",
5131 pr.head_ref_name.trim(),
5132 e.last_line()
5133 )
5134 })?;
5135 remote_head_oid(&listed, &remote_ref)?
5136 };
5137 Ok(LiveHead { pull, branch })
5138 }
5139
5140 pub fn create_pr(
5145 &self,
5146 cwd: &Path,
5147 branch: &str,
5148 target: &PushTarget,
5149 base: &str,
5150 title: &str,
5151 body: &str,
5152 ) -> Result<PrRef> {
5153 let title = self.record_failed_write(self.clean_title(title))?;
5154 let body = self.record_failed_write(self.clean(body))?;
5155 let head = target.head(branch);
5156 let mut argv = vec![
5157 "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
5158 ];
5159 if self.drafts != Drafts::Never {
5160 argv.push("--draft");
5161 }
5162 let created = self.gh_at(Some(cwd), &argv);
5163 let found = self.try_pr_for_branch(branch, base);
5164 self.record_write(reconcile_pr_creation(branch, created, found))
5165 }
5166
5167 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
5168 let body = signed(&self.record_failed_write(self.clean(body))?);
5169 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5170 if has_exact_comment(&comments, &body) {
5171 return Ok(());
5172 }
5173 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
5174 let result = match posted {
5175 Ok(_) => Ok(()),
5176 Err(post_error) => {
5177 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5178 }
5179 };
5180 self.record_write(result)
5181 }
5182
5183 fn pr_is_own(&self, number: i64) -> Result<bool> {
5187 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
5188 let author = serde_json::from_str::<Value>(&text)
5189 .ok()
5190 .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
5191 .unwrap_or_default();
5192 let viewer = self.viewer_login()?.to_string();
5193 Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
5194 }
5195
5196 pub fn submit_review(
5221 &self,
5222 number: i64,
5223 verdict: Option<crate::model::ReviewVerdict>,
5224 body: &str,
5225 full: &str,
5226 commit: &str,
5227 comments: &[InlineComment],
5228 ) -> Result<()> {
5229 let own = match verdict {
5230 Some(_) => self.record_failed_write(self.pr_is_own(number))?,
5231 None => false,
5232 };
5233 let prepare = |body: &str| {
5234 let text = match (verdict, own) {
5235 (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
5236 _ => body.to_string(),
5237 };
5238 self.record_failed_write(self.clean(&text))
5239 .map(|b| signed(&b))
5240 };
5241 let body = prepare(body)?;
5242 let full = prepare(full)?;
5243 let mut inline = Vec::with_capacity(comments.len());
5244 for c in comments {
5245 inline.push(InlineComment {
5246 body: signed(&self.record_failed_write(self.clean(&c.body))?),
5247 ..c.clone()
5248 });
5249 }
5250 let either = [body.as_str(), full.as_str()];
5251 let viewer = self.record_failed_write(self.viewer_login())?.to_string();
5252 let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
5253 if has_viewer_review(&reviews, &viewer, &either, commit) {
5254 return Ok(());
5255 }
5256 let event = match (verdict, own) {
5257 (Some(v), false) => v.api_event(),
5258 _ => "COMMENT",
5259 };
5260 let result = if inline.is_empty() {
5261 self.post_review(number, &full, &viewer, commit, event)
5262 } else {
5263 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5264 let payload = review_payload(commit, event, &body, &inline).to_string();
5265 match self.gh_stdin(
5266 &["api", "-X", "POST", &path, "--input", "-", "--silent"],
5267 &payload,
5268 ) {
5269 Ok(_) => Ok(()),
5270 Err(post_error) if !refused(&post_error) => reconcile_review_post(
5271 number,
5272 &[&body],
5273 &viewer,
5274 commit,
5275 post_error,
5276 self.try_pr_reviews(number),
5277 ),
5278 Err(post_error) => match self.try_pr_reviews(number) {
5279 Ok(reviews) if !has_viewer_review(&reviews, &viewer, &either, commit) => {
5280 logwarn!(
5281 "the review of #{number} with its line comments did not go up ({}), \
5282 so it is going up with every finding in its body only",
5283 post_error.last_line()
5284 );
5285 self.post_review(number, &full, &viewer, commit, event)
5286 }
5287 reviews => {
5288 reconcile_review_post(number, &either, &viewer, commit, post_error, reviews)
5289 }
5290 },
5291 }
5292 };
5293 self.record_write(result)
5294 }
5295
5296 fn post_review(
5298 &self,
5299 number: i64,
5300 body: &str,
5301 viewer: &str,
5302 commit: &str,
5303 event: &str,
5304 ) -> Result<()> {
5305 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5306 let posted = self.gh(&[
5307 "api",
5308 "-X",
5309 "POST",
5310 &path,
5311 "-f",
5312 &format!("commit_id={commit}"),
5313 "-f",
5314 &format!("event={event}"),
5315 "-f",
5316 &format!("body={body}"),
5317 "--silent",
5318 ]);
5319 match posted {
5320 Ok(_) => Ok(()),
5321 Err(post_error) => reconcile_review_post(
5322 number,
5323 &[body],
5324 viewer,
5325 commit,
5326 post_error,
5327 self.try_pr_reviews(number),
5328 ),
5329 }
5330 }
5331
5332 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5333 let body = signed(&self.record_failed_write(self.clean(body))?);
5334 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5335 if has_exact_comment(&comments, &body) {
5336 return Ok(());
5337 }
5338 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5339 let result = match posted {
5340 Ok(_) => Ok(()),
5341 Err(post_error) => {
5342 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5343 }
5344 };
5345 self.record_write(result)
5346 }
5347
5348 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5353 self.comment_issue(number, body)?;
5354 let n = number.to_string();
5355 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5356 Ok(_) => Ok(()),
5357 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5359 spar_err!(
5360 "commented on #{number} but could not close it: {}",
5361 e.last_line()
5362 )
5363 }),
5364 };
5365 self.record_write(closed)
5366 }
5367
5368 pub fn edit_issue_body(
5383 &self,
5384 number: i64,
5385 expected: &str,
5386 body: &str,
5387 inserted: &str,
5388 ) -> Result<()> {
5389 let cleaned = self.record_failed_write(self.clean(inserted))?;
5390 if cleaned.trim() != inserted.trim() {
5391 return self.record_failed_write(Err(spar_err!(
5392 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5393 )));
5394 }
5395 let current = self.record_failed_write(self.issue_body(number))?;
5396 if current != expected {
5397 return self.record_failed_write(Err(spar_err!(
5398 "the body of #{number} changed since it was read, so it was left alone rather \
5399 than written over."
5400 )));
5401 }
5402 let edited = self.gh_stdin(
5403 &["issue", "edit", &number.to_string(), "--body-file", "-"],
5404 body,
5405 );
5406 let result = match edited {
5407 Ok(_) => Ok(()),
5408 Err(edit_error) => {
5409 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5410 }
5411 };
5412 self.record_write(result)
5413 }
5414
5415 pub fn issue_body(&self, number: i64) -> Result<String> {
5417 #[derive(Deserialize)]
5418 struct Row {
5419 #[serde(default)]
5420 body: Option<String>,
5421 }
5422 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5423 let row: Row = serde_json::from_str(text.trim())
5424 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5425 Ok(row.body.unwrap_or_default())
5426 }
5427
5428 pub fn open_issue_rows(&self) -> Vec<Issue> {
5433 let text = self.gh_try(&[
5434 "issue",
5435 "list",
5436 "--state",
5437 "open",
5438 "--limit",
5439 &FETCH_CEILING.to_string(),
5440 "--json",
5441 "number,title,body,labels,state,stateReason,url",
5442 ]);
5443 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5444 }
5445
5446 pub fn open_pr_rows(&self) -> Vec<PrRow> {
5448 let text = self.gh_try(&[
5449 "pr",
5450 "list",
5451 "--state",
5452 "open",
5453 "--limit",
5454 &FETCH_CEILING.to_string(),
5455 "--json",
5456 "number,title,changedFiles,additions,deletions",
5457 ]);
5458 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5459 }
5460
5461 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5462 self.create_issue_apart_from(title, body, None)
5463 }
5464
5465 pub fn create_issue_apart_from(
5466 &self,
5467 title: &str,
5468 body: &str,
5469 apart_from: Option<i64>,
5470 ) -> Result<String> {
5471 let title = self.record_failed_write(self.clean_title(title))?;
5472 let body = self.record_failed_write(self.clean_issue_body(body))?;
5473 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5474 let result = match created {
5475 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5476 created => {
5477 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5478 reconcile_issue_creation(&title, created, found)
5479 }
5480 };
5481 self.record_write(result)
5482 }
5483}
5484
5485#[derive(Debug, Clone)]
5487pub struct ExistingIssue {
5488 pub number: i64,
5489 pub url: String,
5490 pub title: String,
5491 pub body: String,
5492 pub open: bool,
5493}
5494
5495impl Repo {
5496 pub(crate) fn try_exact_issue_apart_from(
5497 &self,
5498 title: &str,
5499 body: &str,
5500 apart_from: Option<i64>,
5501 ) -> Result<Option<ExistingIssue>> {
5502 #[derive(Deserialize)]
5503 #[serde(rename_all = "camelCase")]
5504 struct Row {
5505 number: i64,
5506 #[serde(default)]
5507 title: String,
5508 #[serde(default)]
5509 url: String,
5510 #[serde(default)]
5511 body: Option<String>,
5512 #[serde(default)]
5513 state: String,
5514 }
5515
5516 let text = self.gh(&[
5517 "issue",
5518 "list",
5519 "--state",
5520 "all",
5521 "--limit",
5522 "100",
5523 "--json",
5524 "number,title,url,body,state,stateReason",
5525 ])?;
5526 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5527 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5528 Ok(rows
5529 .into_iter()
5530 .filter(|row| Some(row.number) != apart_from)
5531 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5532 .map(|row| ExistingIssue {
5533 number: row.number,
5534 url: row.url,
5535 title: row.title,
5536 body: row.body.unwrap_or_default(),
5537 open: row.state.eq_ignore_ascii_case("open"),
5538 }))
5539 }
5540
5541 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5549 self.find_similar_issue_apart_from(title, body, None)
5550 }
5551
5552 pub fn find_similar_issue_apart_from(
5560 &self,
5561 title: &str,
5562 body: &str,
5563 apart_from: Option<i64>,
5564 ) -> Option<ExistingIssue> {
5565 self.try_similar_issue(title, body, apart_from, true)
5566 .ok()
5567 .flatten()
5568 }
5569
5570 pub fn try_find_similar_issue_apart_from(
5572 &self,
5573 title: &str,
5574 body: &str,
5575 apart_from: Option<i64>,
5576 ) -> Result<Option<ExistingIssue>> {
5577 self.try_similar_issue(title, body, apart_from, false)
5578 }
5579
5580 fn try_similar_issue(
5582 &self,
5583 title: &str,
5584 body: &str,
5585 apart_from: Option<i64>,
5586 include_closed: bool,
5587 ) -> Result<Option<ExistingIssue>> {
5588 #[derive(Deserialize)]
5589 #[serde(rename_all = "camelCase")]
5590 struct Row {
5591 number: i64,
5592 #[serde(default)]
5593 title: String,
5594 #[serde(default)]
5595 url: String,
5596 #[serde(default)]
5597 body: String,
5598 #[serde(default)]
5599 state: String,
5600 }
5601 if title.trim().is_empty() {
5602 return Ok(None);
5603 }
5604 let query: String = title
5607 .chars()
5608 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5609 .take(120)
5610 .collect();
5611 let state = if include_closed { "all" } else { "open" };
5620 let text = self.gh(&[
5621 "issue",
5622 "list",
5623 "--state",
5624 state,
5625 "--limit",
5626 "100",
5627 "--search",
5628 query.trim(),
5629 "--json",
5630 "number,title,url,body,state,stateReason",
5631 ])?;
5632 let rows: Vec<Row> = serde_json::from_str(text.trim())
5633 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5634
5635 Ok(rows
5636 .into_iter()
5637 .filter(|row| Some(row.number) != apart_from)
5638 .find(|row| {
5639 row.title.trim().eq_ignore_ascii_case(title.trim())
5640 || textsim::covers(title, body, &row.title, &row.body)
5641 })
5642 .map(|row| ExistingIssue {
5643 number: row.number,
5644 url: row.url,
5645 title: row.title,
5646 open: row.state.eq_ignore_ascii_case("open"),
5647 body: row.body,
5648 }))
5649 }
5650
5651 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5653 #[derive(Deserialize)]
5654 struct Row {
5655 title: String,
5656 url: String,
5657 }
5658 let needle = title.trim().to_lowercase();
5659 if needle.is_empty() {
5660 return None;
5661 }
5662 let query: String = title
5664 .chars()
5665 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5666 .take(120)
5667 .collect();
5668 let text = self.gh_try(&[
5669 "issue",
5670 "list",
5671 "--state",
5672 "all",
5673 "--limit",
5674 "100",
5675 "--search",
5676 query.trim(),
5677 "--json",
5678 "number,title,url",
5679 ]);
5680 serde_json::from_str::<Vec<Row>>(text.trim())
5681 .ok()?
5682 .into_iter()
5683 .find(|row| row.title.trim().to_lowercase() == needle)
5684 .map(|row| row.url)
5685 }
5686
5687 pub fn mark_ready(&self, number: i64) -> bool {
5695 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5696 Ok(_) => true,
5697 Err(e) => {
5698 logdim!(
5699 "PR #{number} is approved but could not be taken out of draft: {}",
5700 e.last_line()
5701 );
5702 false
5703 }
5704 }
5705 }
5706
5707 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5709 let listed = self
5710 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5711 .ok()?;
5712 let wanted = format!("branch refs/heads/{branch}");
5713 let mut current: Option<PathBuf> = None;
5714 for line in listed.lines() {
5715 if let Some(path) = line.strip_prefix("worktree ") {
5716 current = Some(PathBuf::from(path));
5717 } else if line == wanted {
5718 return current;
5719 }
5720 }
5721 None
5722 }
5723
5724 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5740 let branch = self.pr_view(number).ok()?.head_ref_name;
5741 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5742 return None;
5743 }
5744 let mut released = None;
5745 if let Some(path) = self.worktree_holding(&branch) {
5746 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5747 return None;
5748 }
5749 if !matches!(
5750 self.has_recoverable_work(&path, Disposable::BuildOutput),
5751 Ok(false)
5752 ) {
5753 return None;
5754 }
5755 match self.remove_worktree_at(&path) {
5756 Ok(true) => released = Some(path),
5757 _ => return None,
5758 }
5759 }
5760 match self.delete_branch_if_safe(&branch) {
5761 Ok(true) => {
5762 self.forget_branch(&branch);
5763 Some(match released {
5764 Some(path) => format!(
5765 " The worktree at {} was released and branch {branch} deleted.",
5766 path.display()
5767 ),
5768 None => format!(" Branch {branch} was deleted."),
5769 })
5770 }
5771 _ => released.map(|path| {
5772 format!(
5773 " The worktree at {} was released, but branch {branch} was kept.",
5774 path.display()
5775 )
5776 }),
5777 }
5778 }
5779
5780 pub fn merge_pr(&self, number: i64) -> Result<()> {
5784 let n = number.to_string();
5785 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5786 Ok(_) => Ok(()),
5787 Err(e) => {
5788 if self.pr_state(number) == "MERGED" {
5789 let finished = self
5790 .finish_merged_branch_cleanup(number)
5791 .unwrap_or_default();
5792 logdim!(
5793 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5794 e.last_line()
5795 );
5796 Ok(())
5797 } else {
5798 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5799 }
5800 }
5801 };
5802 self.record_write(merged)
5803 }
5804
5805 pub fn merge_pr_at_head(
5807 &self,
5808 number: i64,
5809 expected_head: &str,
5810 delete_branch: bool,
5811 ) -> Result<()> {
5812 let n = number.to_string();
5813 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5814 Ok(_) => Ok(()),
5815 Err(e) => {
5816 if self.pr_state(number) == "MERGED" {
5817 let finished = self
5818 .finish_merged_branch_cleanup(number)
5819 .unwrap_or_default();
5820 logdim!(
5821 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5822 e.last_line()
5823 );
5824 Ok(())
5825 } else {
5826 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5827 }
5828 }
5829 };
5830 self.record_write(merged)
5831 }
5832
5833 pub fn followups_path(&self) -> PathBuf {
5838 self.root.join(STATE_DIR).join("followups.md")
5839 }
5840
5841 pub fn brainstorms_dir(&self) -> PathBuf {
5846 self.root.join(STATE_DIR).join("brainstorms")
5847 }
5848
5849 pub fn worked_followups_path(&self) -> PathBuf {
5857 self.root.join(STATE_DIR).join("followups.done.md")
5858 }
5859
5860 pub fn spend_path(&self) -> PathBuf {
5863 self.root.join(STATE_DIR).join("state").join("spend.json")
5864 }
5865
5866 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5872 if calls.is_empty() {
5873 return Ok(());
5874 }
5875 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5876 .ok()
5877 .and_then(|text| serde_json::from_str(&text).ok())
5878 .unwrap_or_default();
5879 all.extend(calls.iter().cloned());
5880 if let Some(parent) = self.spend_path().parent() {
5881 let _ = std::fs::create_dir_all(parent);
5882 }
5883 write_json_atomic(&self.spend_path(), &all)
5884 }
5885
5886 pub fn implementors_path(&self) -> PathBuf {
5892 self.root
5893 .join(STATE_DIR)
5894 .join("state")
5895 .join("implementors.json")
5896 }
5897
5898 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5899 std::fs::read_to_string(self.implementors_path())
5900 .ok()
5901 .and_then(|text| serde_json::from_str(&text).ok())
5902 .unwrap_or_default()
5903 }
5904
5905 pub fn remember_implementor(&self, issue: i64, agent: &str) {
5908 let mut known = self.read_implementors();
5909 known.insert(issue, agent.to_string());
5910 if let Some(parent) = self.implementors_path().parent() {
5911 let _ = std::fs::create_dir_all(parent);
5912 }
5913 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5914 logdim!("could not record who implemented #{issue}: {e}");
5915 }
5916 }
5917
5918 pub fn contested_path(&self) -> PathBuf {
5923 self.root
5924 .join(STATE_DIR)
5925 .join("state")
5926 .join("contested.json")
5927 }
5928
5929 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5931 std::fs::read_to_string(self.contested_path())
5932 .ok()
5933 .and_then(|text| serde_json::from_str(&text).ok())
5934 .unwrap_or_default()
5935 }
5936
5937 pub fn remember_contested(&self, items: &[ContestedItem]) {
5942 if items.is_empty() {
5943 return;
5944 }
5945 let mut known = self.read_contested();
5946 for item in items {
5947 known.insert(
5948 item.issue,
5949 Contested {
5950 title: item.title.clone(),
5951 positions: item.positions.clone(),
5952 reasons: item.reasons.clone(),
5953 },
5954 );
5955 }
5956 if let Some(parent) = self.contested_path().parent() {
5957 let _ = std::fs::create_dir_all(parent);
5958 }
5959 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5960 logdim!("could not record the contested issues: {e}");
5961 }
5962 }
5963
5964 pub fn forget_contested(&self, numbers: &[i64]) {
5966 let mut known = self.read_contested();
5967 let before = known.len();
5968 known.retain(|number, _| !numbers.contains(number));
5969 if known.len() == before {
5970 return;
5971 }
5972 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5973 logdim!("could not update the contested issues: {e}");
5974 }
5975 }
5976
5977 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5978 self.root
5979 .join(STATE_DIR)
5980 .join("state")
5981 .join(format!("checkin-{number}.json"))
5982 }
5983
5984 pub fn answer_state_path(&self, number: i64) -> PathBuf {
5986 self.root
5987 .join(STATE_DIR)
5988 .join("state")
5989 .join(format!("answer-{number}.json"))
5990 }
5991
5992 pub fn answer_notes_path(&self, number: i64) -> PathBuf {
5995 self.root
5996 .join(STATE_DIR)
5997 .join("answers")
5998 .join(format!("issue-{number}.md"))
5999 }
6000
6001 pub fn review_record_path(&self, number: i64) -> PathBuf {
6003 self.root
6004 .join(STATE_DIR)
6005 .join("state")
6006 .join(format!("review-{number}.json"))
6007 }
6008
6009 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
6012 let path = self.review_record_path(number);
6013 let text = std::fs::read_to_string(&path).ok()?;
6014 match serde_json::from_str(&text) {
6015 Ok(record) => Some(record),
6016 Err(e) => {
6017 logdim!("could not read {}: {e}", path.display());
6018 None
6019 }
6020 }
6021 }
6022
6023 pub fn write_review_record(
6024 &self,
6025 number: i64,
6026 record: &crate::model::ReviewRecord,
6027 ) -> Result<()> {
6028 write_json_atomic(&self.review_record_path(number), record)
6029 }
6030
6031 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
6046 let path = self.followups_path();
6047 let heading = format!("## {}", title.trim());
6048 for seen in [&path, &self.worked_followups_path()] {
6049 if let Ok(existing) = std::fs::read_to_string(seen) {
6050 if has_heading(&existing, title) {
6051 logdim!("follow-up already noted: {title}");
6052 return Followup::Covered(format!("note: {}", title.trim()));
6053 }
6054 }
6055 }
6056 if let Some(parent) = path.parent() {
6057 let _ = std::fs::create_dir_all(parent);
6058 }
6059 use std::io::Write;
6060 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
6067 match std::fs::OpenOptions::new()
6068 .create(true)
6069 .append(true)
6070 .open(&path)
6071 {
6072 Ok(mut file) => match file.write_all(entry.as_bytes()) {
6073 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
6074 Err(e) => {
6075 logdim!("could not write {}: {e}", path.display());
6076 Followup::Failed
6077 }
6078 },
6079 Err(e) => {
6080 logdim!("could not write {}: {e}", path.display());
6081 Followup::Failed
6082 }
6083 }
6084 }
6085
6086 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
6091 let path = self.worked_followups_path();
6092 if let Some(parent) = path.parent() {
6093 let _ = std::fs::create_dir_all(parent);
6094 }
6095 use std::io::Write;
6096 let entry = format!(
6097 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
6098 title.trim(),
6099 body.trim()
6100 );
6101 if let Ok(mut file) = std::fs::OpenOptions::new()
6102 .create(true)
6103 .append(true)
6104 .open(&path)
6105 {
6106 let _ = file.write_all(entry.as_bytes());
6107 }
6108 }
6109
6110 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
6119 self.root
6120 .join(STATE_DIR)
6121 .join("reviews")
6122 .join(format!("pr-{number}.md"))
6123 }
6124
6125 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
6131 let path = self.pending_comment_path(number);
6132 if let Some(parent) = path.parent() {
6133 std::fs::create_dir_all(parent)
6134 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6135 }
6136 std::fs::write(&path, text)
6137 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6138 Ok(path)
6139 }
6140
6141 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
6142 std::fs::read_to_string(self.pending_comment_path(number)).ok()
6143 }
6144
6145 pub fn state_path(&self, number: i64) -> PathBuf {
6146 self.root
6147 .join(STATE_DIR)
6148 .join("state")
6149 .join(format!("pr-{number}.json"))
6150 }
6151
6152 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
6153 let path = self.state_path(number);
6154 let text = std::fs::read_to_string(&path).ok()?;
6155 match serde_json::from_str(&text) {
6156 Ok(state) => Some(state),
6157 Err(_) => {
6158 logdim!("could not read {}, starting fresh", path.display());
6159 None
6160 }
6161 }
6162 }
6163
6164 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
6165 if let Some(local) = self.read_local_state(pr.number) {
6166 return Some(local);
6167 }
6168 if self.state_store.writes_pr() {
6169 return self.read_pr_state(pr.number);
6170 }
6171 None
6172 }
6173
6174 pub(crate) fn read_state_for_head(
6175 &self,
6176 pr: &PrView,
6177 actual_head: &str,
6178 ) -> Option<PersistedState> {
6179 let local = self
6180 .state_store
6181 .writes_local()
6182 .then(|| self.read_local_state(pr.number))
6183 .flatten();
6184 let remote = self
6185 .state_store
6186 .writes_pr()
6187 .then(|| self.read_pr_state(pr.number))
6188 .flatten();
6189 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
6190 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
6191 self.remember_checkpoint(pr.number, checkpoint);
6192 }
6193 choose_state_for_head(candidates, actual_head)
6194 }
6195
6196 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
6197 self.try_read_pr_state(number).ok().flatten()
6198 }
6199
6200 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
6201 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
6202 if let Some(state) = parse_state_comment(&body) {
6203 return Ok(Some(state));
6204 }
6205 }
6206 Ok(None)
6207 }
6208
6209 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6210 let remote_state = if self.state_store.writes_pr() {
6211 self.try_read_pr_state(number)
6212 } else {
6213 Ok(None)
6214 };
6215 self.write_state_after_remote_read(number, state, remote_state)
6216 }
6217
6218 fn write_state_after_remote_read(
6219 &self,
6220 number: i64,
6221 state: &PersistedState,
6222 remote_state: Result<Option<PersistedState>>,
6223 ) -> Result<()> {
6224 let remote_checkpoint = if self.state_store.writes_pr() {
6225 self.record_failed_write(remote_state)?
6226 .map(|saved| saved.checkpoint)
6227 .unwrap_or_default()
6228 } else {
6229 0
6230 };
6231 let local_checkpoint = self
6232 .state_store
6233 .writes_local()
6234 .then(|| self.read_local_state(number))
6235 .flatten()
6236 .map(|saved| saved.checkpoint)
6237 .unwrap_or_default();
6238 let mut stamped = state.clone();
6239 stamped.checkpoint = state
6240 .checkpoint
6241 .max(local_checkpoint)
6242 .max(remote_checkpoint)
6243 .max(self.remembered_checkpoint(number))
6244 .saturating_add(1);
6245 self.remember_checkpoint(number, stamped.checkpoint);
6246 if self.state_store.writes_local() {
6247 write_json_atomic(&self.state_path(number), &stamped)?;
6248 }
6249 if self.state_store.writes_pr() {
6250 self.write_pr_state(number, &stamped)?;
6251 }
6252 Ok(())
6253 }
6254
6255 fn remembered_checkpoint(&self, number: i64) -> u64 {
6256 self.checkpoints
6257 .lock()
6258 .unwrap_or_else(std::sync::PoisonError::into_inner)
6259 .get(&number)
6260 .copied()
6261 .unwrap_or_default()
6262 }
6263
6264 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6265 let mut checkpoints = self
6266 .checkpoints
6267 .lock()
6268 .unwrap_or_else(std::sync::PoisonError::into_inner);
6269 let saved = checkpoints.entry(number).or_default();
6270 *saved = (*saved).max(checkpoint);
6271 }
6272
6273 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6274 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6278 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6279 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6280 if let Some(id) = comment_id {
6281 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6282 let field = format!("body={body}");
6283 let written = self
6284 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6285 .map(|_| ());
6286 return self.record_write(written);
6287 }
6288 let written = self
6289 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6290 .map(|_| ());
6291 self.record_write(written)
6292 }
6293
6294 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6302 self.try_issue_comments(number).unwrap_or_default()
6303 }
6304
6305 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6306 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6307 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6308 }
6309
6310 pub fn issue_row(&self, number: i64) -> Result<Value> {
6314 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6315 let text = self
6316 .gh(&["api", &path])
6317 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6318 serde_json::from_str(&text)
6319 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6320 }
6321
6322 pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6325 #[derive(Deserialize)]
6326 struct Row {
6327 number: i64,
6328 }
6329 let name = self
6331 .repo_name()
6332 .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6333 let text = self.gh(&[
6334 "search",
6335 "issues",
6336 "--repo",
6337 &name,
6338 "--mentions",
6339 login,
6340 "--state",
6341 "open",
6342 "--limit",
6343 &FETCH_CEILING.to_string(),
6344 "--json",
6345 "number",
6346 ])?;
6347 let rows: Vec<Row> = serde_json::from_str(text.trim())
6348 .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6349 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6350 numbers.sort_unstable();
6351 numbers.truncate(limit);
6352 Ok(numbers)
6353 }
6354
6355 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6362 let viewer = self.viewer_login()?.to_string();
6363 let mut mine = Vec::new();
6364 let mut theirs = Vec::new();
6365 for comment in self.try_issue_comments(number)? {
6366 let Some(body) = comment.get("body").and_then(Value::as_str) else {
6367 continue;
6368 };
6369 if !body.contains("spar:state") {
6370 continue;
6371 }
6372 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6373 continue;
6374 };
6375 let author = comment
6376 .get("user")
6377 .and_then(|u| u.get("login"))
6378 .and_then(Value::as_str)
6379 .unwrap_or_default();
6380 if is_state_comment(body, author, &viewer) {
6381 mine.push((id, body.to_string()));
6382 } else {
6383 theirs.push(format!("@{author}"));
6384 }
6385 }
6386 if !theirs.is_empty() {
6387 logdim!(
6388 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6389 own state block is patched or deleted.",
6390 theirs.len(),
6391 theirs.join(", ")
6392 );
6393 }
6394 Ok(mine)
6395 }
6396
6397 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6398 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6399 }
6400
6401 pub fn clear_state(&self, number: i64) {
6403 let path = self.state_path(number);
6404 let _ = std::fs::remove_file(&path);
6405 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6406 }
6407
6408 pub fn prune_state(&self) -> Vec<String> {
6415 let base = self.root.join(STATE_DIR).join("state");
6416 let Ok(entries) = std::fs::read_dir(&base) else {
6417 return Vec::new();
6418 };
6419 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6420 for name in entries
6421 .flatten()
6422 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6423 {
6424 if let Some(number) = state_file_number(&name) {
6425 by_number.entry(number).or_default().push(name);
6426 }
6427 }
6428
6429 let mut removed = Vec::new();
6430 for (number, mut names) in by_number {
6431 if !is_finished(&self.pr_state(number)) {
6432 continue;
6433 }
6434 names.sort();
6435 for name in names {
6436 let _ = std::fs::remove_file(base.join(&name));
6437 removed.push(format!("state {name}"));
6438 }
6439 }
6440 removed
6441 }
6442
6443 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6447 #[derive(Deserialize)]
6448 struct Row {
6449 number: i64,
6450 }
6451 let numbers = match numbers {
6452 Some(numbers) => numbers,
6453 None => {
6454 let listed: Result<Vec<i64>> = (|| {
6455 let text = self.gh(&[
6456 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6457 ])?;
6458 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6459 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6460 Ok(rows.into_iter().map(|row| row.number).collect())
6461 })();
6462 match self.record_failed_write(listed) {
6463 Ok(numbers) => numbers,
6464 Err(e) => {
6465 logdim!("could not inspect pull requests for state cleanup: {e}");
6466 return Vec::new();
6467 }
6468 }
6469 }
6470 };
6471
6472 let mut removed = Vec::new();
6473 for number in numbers {
6474 let state = match self.record_failed_write(self.try_pr_state(number)) {
6475 Ok(state) => state,
6476 Err(e) => {
6477 logdim!("could not inspect PR #{number} for state cleanup: {e}");
6478 continue;
6479 }
6480 };
6481 if !is_finished(&state) {
6482 continue;
6483 }
6484 let comments = match self.record_failed_write(self.try_state_comments(number)) {
6485 Ok(comments) => comments,
6486 Err(e) => {
6487 logdim!("could not inspect state comments on PR #{number}: {e}");
6488 continue;
6489 }
6490 };
6491 for (id, _) in comments {
6492 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6493 let deleted = self
6494 .gh(&["api", "-X", "DELETE", &path, "--silent"])
6495 .map(|_| ());
6496 match self.record_write(deleted) {
6497 Ok(()) => removed.push(format!("state comment on PR #{number}")),
6498 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6499 }
6500 }
6501 }
6502 removed
6503 }
6504
6505 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6512 let base = self.root.join(WORKTREE_DIR);
6513 let mut removed = Vec::new();
6514 let known = self.known_branches();
6515
6516 if let Ok(entries) = std::fs::read_dir(&base) {
6517 let mut names: Vec<String> = entries
6518 .flatten()
6519 .filter(|e| e.path().is_dir())
6520 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6521 .collect();
6522 names.sort();
6523
6524 for name in names {
6525 if let Some(owner) = read_checkout_owner(&name) {
6529 let path = base.join(&name);
6530 if force_all {
6531 self.remove_worktree_at_force(&path);
6532 } else if process_is_running(owner) {
6533 continue;
6534 } else if has_recovery_marker(&path) {
6535 logdim!("kept {} for recovery", path.display());
6536 continue;
6537 } else {
6538 self.discard_read_checkout(&path);
6539 }
6540 if !path.exists() {
6541 removed.push(name);
6542 }
6543 continue;
6544 }
6545 if let Some(rest) = name.strip_prefix("review-") {
6548 let number: i64 = rest.parse().unwrap_or(-1);
6549 if !(force_all || is_finished(&self.pr_state(number))) {
6550 continue;
6551 }
6552 let path = base.join(&name);
6553 if force_all {
6554 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6555 if !belongs {
6556 return Ok(false);
6557 }
6558 let local_ref = review_ref(number);
6559 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6560 return Ok(false);
6561 }
6562 let head = self.head_oid_checked(&path)?;
6563 let recorded = self
6564 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6565 .trim()
6566 .to_string();
6567 Ok(head == recorded)
6568 });
6569 match owned {
6570 Ok(true) => {}
6571 Ok(false) => {
6572 logdim!(
6573 "kept {} because no matching SPAR review reference proves \
6574 ownership",
6575 path.display()
6576 );
6577 continue;
6578 }
6579 Err(e) => {
6580 logdim!(
6581 "kept {} because review ownership could not be verified: {}",
6582 path.display(),
6583 e.last_line()
6584 );
6585 continue;
6586 }
6587 }
6588 } else {
6589 if let Err(e) = self.refuse_review_worktree_changes(number) {
6590 logdim!(
6591 "kept {} because its review state could not be verified as \
6592 disposable: {}",
6593 path.display(),
6594 e.last_line()
6595 );
6596 continue;
6597 }
6598 }
6599 if force_all {
6600 if self.remove_worktree_at_force(&path) {
6601 self.git_try(&["update-ref", "-d", &review_ref(number)]);
6602 }
6603 } else {
6604 self.release_review_worktree(number);
6605 }
6606 if !path.exists() {
6607 removed.push(name);
6608 }
6609 continue;
6610 }
6611 let branch = format!("{}{name}", self.branch_prefix);
6612 if !(force_all || self.worktree_is_done(&branch)) {
6613 continue;
6614 }
6615 if !known.contains_key(&branch) {
6616 logdim!("kept {branch} because it has no branch record");
6617 continue;
6618 }
6619 let path = base.join(&name);
6620 if !force_all {
6621 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6622 Ok(true) => {
6623 logdim!(
6624 "kept {} because it contains uncommitted changes or ignored files",
6625 path.display()
6626 );
6627 continue;
6628 }
6629 Err(e) => {
6630 logdim!(
6631 "kept {} because its Git state could not be checked: {}",
6632 path.display(),
6633 e.last_line()
6634 );
6635 continue;
6636 }
6637 Ok(false) => {}
6638 }
6639 match self.branch_deletion_is_safe(&branch) {
6640 Ok(true) => {}
6641 Ok(false) => {
6642 logdim!(
6643 "kept {branch} because no surviving ref preserves its tip or \
6644 reflog-only commits"
6645 );
6646 continue;
6647 }
6648 Err(e) => {
6649 logdim!(
6650 "kept {branch} because preservation could not be verified: {}",
6651 e.last_line()
6652 );
6653 continue;
6654 }
6655 }
6656 }
6657 let removed_worktree = if force_all {
6658 self.remove_worktree_at_force(&path)
6659 } else {
6660 match self.remove_worktree_at(&path) {
6661 Ok(removed) => removed,
6662 Err(error) => {
6663 logdim!(
6664 "kept {branch} and {} because removal did not reach a confirmed \
6665 quiet point: {}",
6666 path.display(),
6667 error.last_line()
6668 );
6669 false
6670 }
6671 }
6672 };
6673 if !removed_worktree {
6674 continue;
6675 }
6676 if force_all {
6677 self.git_try(&["branch", "-D", &branch]);
6678 self.forget_branch(&branch);
6679 } else {
6680 match self.delete_branch_if_safe(&branch) {
6681 Ok(true) => self.forget_branch(&branch),
6682 Ok(false) => logdim!(
6683 "kept {branch} because its tip or reflog changed before deletion"
6684 ),
6685 Err(error) => logdim!(
6686 "kept {branch} because deletion safety could not be rechecked: {}",
6687 error.last_line()
6688 ),
6689 }
6690 }
6691 removed.push(name);
6692 }
6693 }
6694 removed.extend(self.prune_branches(force_all));
6695 removed
6696 }
6697
6698 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6705 let known = self.known_branches();
6706 let branches: Vec<String> = known.keys().cloned().collect();
6707 if branches.is_empty() {
6708 return Vec::new();
6709 }
6710
6711 let checked_out: Vec<String> = self
6712 .git_try(&["worktree", "list", "--porcelain"])
6713 .lines()
6714 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6715 .collect();
6716
6717 let existing: Vec<String> = self
6720 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6721 .lines()
6722 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6723 .collect();
6724
6725 let mut removed = Vec::new();
6726 for branch in branches {
6727 if !existing.contains(&branch) {
6728 self.forget_branch(&branch); continue;
6730 }
6731 if checked_out.contains(&branch) {
6732 continue;
6733 }
6734 if !(force_all || self.worktree_is_done(&branch)) {
6735 continue;
6736 }
6737 if !force_all {
6738 let Some(_record) = known.get(&branch) else {
6739 continue;
6740 };
6741 match self.branch_deletion_is_safe(&branch) {
6742 Ok(true) => {}
6743 Ok(false) => {
6744 logdim!(
6745 "kept {branch} because no surviving ref preserves its tip or \
6746 reflog-only commits"
6747 );
6748 continue;
6749 }
6750 Err(e) => {
6751 logdim!(
6752 "kept {branch} because preservation could not be verified: {}",
6753 e.last_line()
6754 );
6755 continue;
6756 }
6757 }
6758 }
6759 let deleted = if force_all {
6760 self.git(&["branch", "-D", &branch]).map(|_| true)
6761 } else {
6762 self.delete_branch_if_safe(&branch)
6763 };
6764 match deleted {
6765 Ok(true) => {
6766 self.forget_branch(&branch);
6767 removed.push(format!("branch {branch}"));
6768 }
6769 Ok(false) => {
6770 logdim!("kept {branch} because its tip or reflog changed before deletion");
6771 }
6772 Err(e) => {
6773 logdim!("could not delete {branch}: {}", e.last_line());
6776 }
6777 }
6778 }
6779 removed
6780 }
6781
6782 fn worktree_is_done(&self, branch: &str) -> bool {
6784 #[derive(Deserialize)]
6785 struct Row {
6786 state: String,
6787 }
6788 let entry = branch
6789 .strip_prefix(self.branch_prefix.as_str())
6790 .unwrap_or(branch);
6791 if let Some(rest) = entry.strip_prefix("pr-") {
6792 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6793 }
6794 if entry.starts_with("issue-") || entry.starts_with("split-") {
6798 let text = self.gh_try(&[
6799 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6800 ]);
6801 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6802 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6803 }
6804 false
6805 }
6806}
6807
6808pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6820 match probe() {
6821 Ok(value) => Ok(value),
6822 Err(first) => {
6823 logdim!("retrying a worktree probe after: {}", first.last_line());
6824 probe()
6825 }
6826 }
6827}
6828
6829pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6835 let root = std::fs::canonicalize(cwd)
6836 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6837 let mut files = BTreeMap::new();
6838 let mut visited = BTreeSet::new();
6839 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6840 Ok(AttributeState { files })
6841}
6842
6843fn collect_attribute_files(
6844 root: &Path,
6845 repository: &Path,
6846 prefix: &Path,
6847 visited: &mut BTreeSet<PathBuf>,
6848 files: &mut BTreeMap<PathBuf, [u8; 32]>,
6849) -> Result<()> {
6850 let canonical = std::fs::canonicalize(repository)
6851 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6852 if !visited.insert(canonical) {
6853 bail!("submodule recursion revisited {}", repository.display());
6854 }
6855 let entries = index_entries(repository)?;
6856 let mut paths: BTreeSet<PathBuf> = entries
6857 .iter()
6858 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6859 .map(|entry| entry.path.clone())
6860 .collect();
6861 paths.extend(untracked_attribute_paths(repository)?);
6867 for path in paths {
6868 let from_root = prefix.join(&path);
6869 let state = attribute_file_fingerprint(&root.join(&from_root))?;
6870 files.insert(from_root, state);
6871 }
6872 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6873 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6874 continue;
6875 };
6876 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6877 }
6878 Ok(())
6879}
6880
6881pub(crate) fn uncertain_worktree_change(
6884 cwd: &Path,
6885 message: impl Into<String>,
6886) -> crate::error::SparError {
6887 let message = message.into();
6888 let marker = write_recovery_marker(cwd, &message);
6889 let note = match marker {
6890 Ok(path) => format!(" Recovery marker: {}.", path.display()),
6891 Err(e) => format!(
6892 " A recovery marker could not be written: {}.",
6893 e.last_line()
6894 ),
6895 };
6896 crate::error::SparError::uncertain_write(format!("{message}{note}"))
6897}
6898
6899fn has_recovery_marker(cwd: &Path) -> bool {
6900 let Ok(entries) = std::fs::read_dir(cwd) else {
6901 return false;
6902 };
6903 entries.flatten().any(|entry| {
6904 entry
6905 .file_name()
6906 .to_str()
6907 .is_some_and(|name| name.starts_with(RECOVERY_MARKER_PREFIX))
6908 })
6909}
6910
6911fn read_checkout_owner(name: &str) -> Option<u32> {
6913 let rest = name.strip_prefix(READ_CHECKOUT_PREFIX)?;
6914 let (pid, serial) = rest.split_once('-')?;
6915 serial.parse::<usize>().ok()?;
6916 pid.parse().ok()
6917}
6918
6919#[cfg(unix)]
6925fn process_is_running(pid: u32) -> bool {
6926 let Ok(pid) = libc::pid_t::try_from(pid) else {
6927 return true;
6928 };
6929 if pid <= 0 {
6930 return true;
6931 }
6932 if unsafe { libc::kill(pid, 0) } == 0 {
6933 return true;
6934 }
6935 std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
6936}
6937
6938#[cfg(not(unix))]
6939fn process_is_running(_pid: u32) -> bool {
6940 true
6941}
6942
6943fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6944 use std::sync::atomic::{AtomicU32, Ordering};
6945 static NEXT: AtomicU32 = AtomicU32::new(0);
6946 for _ in 0..1000 {
6947 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6948 let path = cwd.join(format!(
6949 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6950 std::process::id()
6951 ));
6952 let mut options = OpenOptions::new();
6953 options.write(true).create_new(true);
6954 #[cfg(unix)]
6955 {
6956 use std::os::unix::fs::OpenOptionsExt;
6957 options.mode(0o600);
6958 }
6959 match options.open(&path) {
6960 Ok(mut file) => {
6961 file.write_all(detail.as_bytes())
6962 .and_then(|_| file.write_all(b"\n"))
6963 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6964 return Ok(path);
6965 }
6966 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6967 Err(e) => {
6968 return Err(spar_err!(
6969 "could not create a recovery marker in {}: {e}",
6970 cwd.display()
6971 ))
6972 }
6973 }
6974 }
6975 bail!(
6976 "could not choose a free recovery marker name in {}",
6977 cwd.display()
6978 )
6979}
6980
6981fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6986 let mut argv = vec![
6987 "git".to_string(),
6988 "-c".to_string(),
6989 "maintenance.auto=false".to_string(),
6990 "-c".to_string(),
6991 "gc.auto=0".to_string(),
6992 ];
6993 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6994 argv
6995}
6996
6997fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6998 let mut argv = git_without_maintenance_argv(&[]);
6999 argv.extend([
7000 "-c".to_string(),
7001 "core.fsmonitor=".to_string(),
7002 "-c".to_string(),
7003 "commit.gpgsign=false".to_string(),
7004 "-c".to_string(),
7005 "core.hooksPath=/dev/null".to_string(),
7006 ]);
7007 argv.extend(args.iter().map(|arg| (*arg).to_string()));
7008 argv
7009}
7010
7011pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
7018 let root = std::fs::canonicalize(cwd)
7019 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7020 let mut files = BTreeMap::new();
7021 let mut ignored = BTreeSet::new();
7022 let mut visited = BTreeSet::new();
7023 collect_untracked_files(
7024 &root,
7025 &root,
7026 Path::new(""),
7027 &mut visited,
7028 &mut files,
7029 &mut ignored,
7030 )?;
7031 Ok(IgnoredState { files, ignored })
7032}
7033
7034fn collect_untracked_files(
7035 root: &Path,
7036 repository: &Path,
7037 prefix: &Path,
7038 visited: &mut BTreeSet<PathBuf>,
7039 files: &mut BTreeMap<PathBuf, UntrackedFile>,
7040 ignored: &mut BTreeSet<PathBuf>,
7041) -> Result<()> {
7042 let canonical = std::fs::canonicalize(repository)
7043 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7044 if !visited.insert(canonical.clone()) {
7045 bail!("submodule recursion revisited {}", canonical.display());
7046 }
7047 let listed = run_git_bytes(
7051 repository,
7052 &["ls-files", "--others", "--exclude-standard", "-z"],
7053 )?;
7054 if !listed.is_empty() && !listed.ends_with(&[0]) {
7055 bail!(
7056 "git returned an unterminated untracked-file list for {}",
7057 repository.display()
7058 );
7059 }
7060
7061 for raw in listed
7062 .split(|byte| *byte == 0)
7063 .filter(|raw| !raw.is_empty())
7064 {
7065 let (relative, nested) = untracked_record(raw, "untracked")?;
7066 let from_root = prefix.join(&relative);
7067 if is_spar_own_path(&from_root) {
7068 continue;
7069 }
7070 let absolute = root.join(&from_root);
7071 let fingerprint = if nested {
7072 nested_repository_fingerprint(&absolute)?
7073 } else {
7074 ignored_file_fingerprint(&absolute)?
7075 };
7076 if files.insert(from_root.clone(), fingerprint).is_some() {
7077 bail!(
7078 "git returned the untracked path more than once: {:?}",
7079 from_root
7080 );
7081 }
7082 }
7083
7084 let ignored_listed = run_git_bytes(
7085 repository,
7086 &[
7087 "ls-files",
7088 "--others",
7089 "--ignored",
7090 "--exclude-standard",
7091 "-z",
7092 ],
7093 )?;
7094 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
7095 bail!(
7096 "git returned an unterminated ignored-file list for {}",
7097 repository.display()
7098 );
7099 }
7100 for raw in ignored_listed
7106 .split(|byte| *byte == 0)
7107 .filter(|raw| !raw.is_empty())
7108 {
7109 let (relative, nested) = untracked_record(raw, "ignored")?;
7110 let from_root = prefix.join(relative);
7111 if is_spar_own_path(&from_root) {
7112 continue;
7113 }
7114 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
7115 let absolute = root.join(&from_root);
7116 let fingerprint = if nested {
7117 nested_repository_fingerprint(&absolute)?
7118 } else {
7119 ignored_file_fingerprint(&absolute)?
7120 };
7121 slot.insert(fingerprint);
7122 }
7123 if !ignored.insert(from_root.clone()) {
7124 bail!(
7125 "git returned the ignored path more than once: {:?}",
7126 from_root
7127 );
7128 }
7129 }
7130
7131 for link in gitlinks(repository)? {
7132 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7133 continue;
7134 };
7135 collect_untracked_files(
7136 root,
7137 &submodule,
7138 &prefix.join(&link.path),
7139 visited,
7140 files,
7141 ignored,
7142 )?;
7143 }
7144 Ok(())
7145}
7146
7147fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
7148 let argv = git_without_automation_argv(args);
7149 proc::run_bytes(
7150 &argv,
7151 &ExecOpts::new()
7152 .cwd(cwd)
7153 .timeout_secs(30)
7154 .stop_descendants(true),
7155 )
7156}
7157
7158fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
7159 let argv = git_without_automation_argv(args);
7160 proc::run(
7161 &argv,
7162 &ExecOpts::new()
7163 .cwd(cwd)
7164 .timeout_secs(30)
7165 .stop_descendants(true),
7166 )
7167}
7168
7169fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
7170 let path = path.to_str().ok_or_else(|| {
7171 spar_err!(
7172 "cannot verify filtered content for a non-UTF-8 path in {}",
7173 cwd.display()
7174 )
7175 })?;
7176 let path_arg = format!("--path={path}");
7177 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
7178 Ok(Sha256::digest(bytes).into())
7179}
7180
7181fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
7182 let relative = path_from_git_bytes(raw)?;
7183 if relative.is_absolute()
7184 || relative.components().any(|component| {
7185 matches!(
7186 component,
7187 std::path::Component::ParentDir
7188 | std::path::Component::RootDir
7189 | std::path::Component::Prefix(_)
7190 )
7191 })
7192 {
7193 bail!("git returned an unsafe {kind} path: {:?}", relative);
7194 }
7195 Ok(relative)
7196}
7197
7198fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
7207 let nested = raw.last() == Some(&b'/');
7208 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
7209 if trimmed.is_empty() {
7210 bail!("git returned an empty {kind} path");
7211 }
7212 Ok((safe_git_path(trimmed, kind)?, nested))
7213}
7214
7215fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
7216 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
7217 if !listed.is_empty() && !listed.ends_with(&[0]) {
7218 bail!(
7219 "git returned an unterminated index listing for {}",
7220 cwd.display()
7221 );
7222 }
7223 let mut entries = Vec::new();
7224 for record in listed
7225 .split(|byte| *byte == 0)
7226 .filter(|record| !record.is_empty())
7227 {
7228 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7229 bail!(
7230 "git returned a malformed index record for {}",
7231 cwd.display()
7232 );
7233 };
7234 let header = &record[..tab];
7235 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
7236 if fields.len() != 3 {
7237 bail!(
7238 "git returned a malformed index header for {}",
7239 cwd.display()
7240 );
7241 }
7242 if fields[2] != b"0" {
7243 continue;
7244 }
7245 let mode = std::str::from_utf8(fields[0])
7246 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
7247 .to_string();
7248 let oid = std::str::from_utf8(fields[1])
7249 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7250 .to_string();
7251 entries.push(IndexEntry {
7252 path: safe_git_path(&record[tab + 1..], "index")?,
7253 mode,
7254 oid,
7255 });
7256 }
7257 Ok(entries)
7258}
7259
7260fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
7262 let listed = run_git_bytes(
7263 cwd,
7264 &[
7265 "ls-files",
7266 "--others",
7267 "--exclude-standard",
7268 "-z",
7269 "--",
7270 ".gitattributes",
7271 ":(glob)**/.gitattributes",
7272 ],
7273 )?;
7274 if !listed.is_empty() && !listed.ends_with(&[0]) {
7275 bail!(
7276 "git returned an unterminated attribute-file listing for {}",
7277 cwd.display()
7278 );
7279 }
7280 listed
7281 .split(|byte| *byte == 0)
7282 .filter(|record| !record.is_empty())
7283 .map(|raw| safe_git_path(raw, "attribute"))
7284 .collect()
7285}
7286
7287fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
7288 if !untracked_attribute_paths(cwd)?.is_empty() {
7289 return Ok(true);
7290 }
7291
7292 let index = index_entries(cwd)?
7293 .into_iter()
7294 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7295 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7296 .collect::<BTreeMap<_, _>>();
7297 let head = tree_entries(cwd, "HEAD")?
7298 .into_iter()
7299 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7300 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7301 .collect::<BTreeMap<_, _>>();
7302 if index != head {
7303 return Ok(true);
7304 }
7305
7306 let effective = check_attributes(cwd, index.keys().cloned())?;
7307 let config = CheckoutConfig::read(cwd)?;
7308 for (path, (_mode, oid)) in index {
7309 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
7310 return Ok(true);
7311 };
7312 let attributes = effective
7313 .get(&path)
7314 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
7315 if allows_expected_crlf(&config, attributes)? {
7316 if worktree.mode == "120000" {
7317 return Ok(true);
7318 }
7319 let (normalized, every_lf_was_crlf) =
7320 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7321 if !every_lf_was_crlf || normalized != oid {
7322 return Ok(true);
7323 }
7324 } else if worktree.raw_oid != oid {
7325 return Ok(true);
7326 }
7327 }
7328 Ok(false)
7329}
7330
7331fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7332 Ok(index_entries(cwd)?
7333 .into_iter()
7334 .filter(|entry| entry.mode == "160000")
7335 .map(|entry| Gitlink {
7336 path: entry.path,
7337 oid: entry.oid,
7338 })
7339 .collect())
7340}
7341
7342fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7343 let mut tracked = BTreeMap::new();
7344 for entry in index_entries(cwd)? {
7345 if entry.mode == "160000" {
7346 continue;
7347 }
7348 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7349 tracked.insert(
7350 entry.path,
7351 TrackedEntry {
7352 index_mode: entry.mode,
7353 index_oid: entry.oid,
7354 worktree,
7355 },
7356 );
7357 }
7358 Ok(tracked)
7359}
7360
7361fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7362 let metadata = match std::fs::symlink_metadata(path) {
7363 Ok(metadata) => metadata,
7364 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7365 Err(e) => {
7366 return Err(spar_err!(
7367 "could not inspect tracked file {}: {e}",
7368 path.display()
7369 ))
7370 }
7371 };
7372 let mut fingerprint = Sha256::new();
7373 if metadata.file_type().is_symlink() {
7374 let target = std::fs::read_link(path)
7375 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7376 let bytes = os_str_bytes(target.as_os_str())?;
7377 fingerprint.update(b"symlink\0");
7378 fingerprint.update(&bytes);
7379 let content = Sha256::digest(&bytes).into();
7380 return Ok(Some(WorktreeFile {
7381 mode: "120000".to_string(),
7382 #[cfg(unix)]
7383 permissions: 0,
7384 raw_oid: git_blob_oid(oid_len, &bytes)?,
7385 fingerprint: fingerprint.finalize().into(),
7386 content,
7387 }));
7388 }
7389 if !metadata.is_file() {
7390 bail!("tracked path {} is not a file or symlink", path.display());
7391 }
7392
7393 let mut options = OpenOptions::new();
7394 options.read(true);
7395 #[cfg(unix)]
7396 {
7397 use std::os::unix::fs::OpenOptionsExt;
7398 options.custom_flags(libc::O_NOFOLLOW);
7399 }
7400 let mut file = options
7401 .open(path)
7402 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7403 let before = file
7404 .metadata()
7405 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7406 let mode = tracked_file_mode(&before);
7407 #[cfg(unix)]
7408 let permissions = {
7409 use std::os::unix::fs::MetadataExt;
7410 before.mode() & 0o7777
7411 };
7412 fingerprint.update(b"file\0");
7413 fingerprint.update(mode.as_bytes());
7414 #[cfg(unix)]
7415 fingerprint.update(permissions.to_le_bytes());
7416 fingerprint.update(before.len().to_le_bytes());
7417 let mut content = Sha256::new();
7418 let header = format!("blob {}\0", before.len());
7419 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7420 let mut buf = [0u8; 64 * 1024];
7421 loop {
7422 let read = file
7423 .read(&mut buf)
7424 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7425 if read == 0 {
7426 break;
7427 }
7428 fingerprint.update(&buf[..read]);
7429 content.update(&buf[..read]);
7430 object.update(&buf[..read]);
7431 }
7432 let after = file
7433 .metadata()
7434 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7435 if before.len() != after.len()
7436 || before.modified().ok() != after.modified().ok()
7437 || before.permissions() != after.permissions()
7438 {
7439 bail!(
7440 "tracked file {} changed while it was being inspected",
7441 path.display()
7442 );
7443 }
7444 let current = std::fs::symlink_metadata(path)
7445 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7446 if !same_file(&after, ¤t) {
7447 bail!(
7448 "tracked file {} was replaced while it was being inspected",
7449 path.display()
7450 );
7451 }
7452 Ok(Some(WorktreeFile {
7453 mode,
7454 #[cfg(unix)]
7455 permissions,
7456 raw_oid: object.finish(),
7457 fingerprint: fingerprint.finalize().into(),
7458 content: content.finalize().into(),
7459 }))
7460}
7461
7462fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7463 let metadata = std::fs::symlink_metadata(path)
7464 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7465 let mut digest = Sha256::new();
7466 if metadata.file_type().is_symlink() {
7467 digest.update(b"symlink\0");
7468 let target = std::fs::read_link(path)
7469 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7470 digest.update(os_str_bytes(target.as_os_str())?);
7471 return Ok(digest.finalize().into());
7472 }
7473 if !metadata.is_file() {
7474 bail!("attribute path {} is not a file or symlink", path.display());
7475 }
7476 let mut options = OpenOptions::new();
7477 options.read(true);
7478 #[cfg(unix)]
7479 {
7480 use std::os::unix::fs::OpenOptionsExt;
7481 options.custom_flags(libc::O_NOFOLLOW);
7482 }
7483 let mut file = options
7484 .open(path)
7485 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7486 let before = file
7487 .metadata()
7488 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7489 digest.update(b"file\0");
7490 let mut buf = [0u8; 64 * 1024];
7491 loop {
7492 let read = file
7493 .read(&mut buf)
7494 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7495 if read == 0 {
7496 break;
7497 }
7498 digest.update(&buf[..read]);
7499 }
7500 let after = file
7501 .metadata()
7502 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7503 let current = std::fs::symlink_metadata(path)
7504 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7505 if before.len() != after.len()
7506 || before.modified().ok() != after.modified().ok()
7507 || !same_file(&after, ¤t)
7508 {
7509 bail!(
7510 "attribute file {} changed while it was being inspected",
7511 path.display()
7512 );
7513 }
7514 Ok(digest.finalize().into())
7515}
7516
7517enum ObjectHasher {
7518 Sha1(Sha1),
7519 Sha256(Sha256),
7520}
7521
7522impl ObjectHasher {
7523 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7524 let mut hasher = match oid_len {
7525 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7526 64 => Self::Sha256(Sha256::new()),
7527 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7528 };
7529 hasher.update(header);
7530 Ok(hasher)
7531 }
7532
7533 fn update(&mut self, bytes: &[u8]) {
7534 match self {
7535 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7536 Self::Sha256(hasher) => hasher.update(bytes),
7537 }
7538 }
7539
7540 fn finish(self) -> String {
7541 let bytes = match self {
7542 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7543 Self::Sha256(hasher) => hasher.finalize().to_vec(),
7544 };
7545 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7546 }
7547}
7548
7549fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7550 let header = format!("blob {}\0", bytes.len());
7551 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7552 hasher.update(bytes);
7553 Ok(hasher.finish())
7554}
7555
7556fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7557 let mut first = open_regular_file(path)?;
7558 let first_before = first
7559 .metadata()
7560 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7561 let mut raw_len = 0u64;
7562 let mut crlf_pairs = 0u64;
7563 let mut previous_was_cr = false;
7564 let mut every_lf_was_crlf = true;
7565 let mut buf = [0u8; 64 * 1024];
7566 loop {
7567 let read = first
7568 .read(&mut buf)
7569 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7570 if read == 0 {
7571 break;
7572 }
7573 raw_len = raw_len
7574 .checked_add(read as u64)
7575 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7576 for byte in &buf[..read] {
7577 if *byte == b'\n' {
7578 if previous_was_cr {
7579 crlf_pairs += 1;
7580 } else {
7581 every_lf_was_crlf = false;
7582 }
7583 }
7584 previous_was_cr = *byte == b'\r';
7585 }
7586 }
7587 let first_after = first
7588 .metadata()
7589 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7590 let current = std::fs::symlink_metadata(path)
7591 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7592 if raw_len != first_before.len()
7593 || !stable_file_metadata(&first_before, &first_after)
7594 || !stable_file_metadata(&first_after, ¤t)
7595 {
7596 bail!(
7597 "tracked file {} changed while line endings were inspected",
7598 path.display()
7599 );
7600 }
7601
7602 let normalized_len = raw_len
7603 .checked_sub(crlf_pairs)
7604 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7605 let header = format!("blob {normalized_len}\0");
7606 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7607 let mut second = open_regular_file(path)?;
7608 let second_before = second
7609 .metadata()
7610 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7611 if !stable_file_metadata(&first_after, &second_before) {
7612 bail!(
7613 "tracked file {} changed between line-ending checks",
7614 path.display()
7615 );
7616 }
7617 let mut pending_cr = false;
7618 loop {
7619 let read = second
7620 .read(&mut buf)
7621 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7622 if read == 0 {
7623 break;
7624 }
7625 for byte in &buf[..read] {
7626 if pending_cr {
7627 if *byte == b'\n' {
7628 object.update(b"\n");
7629 pending_cr = false;
7630 continue;
7631 }
7632 object.update(b"\r");
7633 pending_cr = false;
7634 }
7635 if *byte == b'\r' {
7636 pending_cr = true;
7637 } else {
7638 object.update(std::slice::from_ref(byte));
7639 }
7640 }
7641 }
7642 if pending_cr {
7643 object.update(b"\r");
7644 }
7645 let second_after = second
7646 .metadata()
7647 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7648 let current = std::fs::symlink_metadata(path)
7649 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7650 if !stable_file_metadata(&second_before, &second_after)
7651 || !stable_file_metadata(&second_after, ¤t)
7652 {
7653 bail!(
7654 "tracked file {} changed while line endings were hashed",
7655 path.display()
7656 );
7657 }
7658 Ok((object.finish(), every_lf_was_crlf))
7659}
7660
7661fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7662 let mut options = OpenOptions::new();
7663 options.read(true);
7664 #[cfg(unix)]
7665 {
7666 use std::os::unix::fs::OpenOptionsExt;
7667 options.custom_flags(libc::O_NOFOLLOW);
7668 }
7669 let file = options
7670 .open(path)
7671 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7672 let metadata = file
7673 .metadata()
7674 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7675 if !metadata.is_file() {
7676 bail!("tracked path {} is not a regular file", path.display());
7677 }
7678 Ok(file)
7679}
7680
7681fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7682 if !same_file(left, right)
7683 || left.len() != right.len()
7684 || left.modified().ok() != right.modified().ok()
7685 || left.permissions() != right.permissions()
7686 {
7687 return false;
7688 }
7689 #[cfg(unix)]
7690 {
7691 use std::os::unix::fs::MetadataExt;
7692 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7693 }
7694 #[cfg(not(unix))]
7695 {
7696 left.created().ok() == right.created().ok()
7697 }
7698}
7699
7700const ATTRIBUTE_BATCH: usize = 1000;
7707
7708fn check_attributes(
7709 cwd: &Path,
7710 paths: impl IntoIterator<Item = PathBuf>,
7711) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7712 let paths = paths.into_iter().collect::<BTreeSet<_>>();
7713 if paths.is_empty() {
7714 return Ok(BTreeMap::new());
7715 }
7716 let ordered = paths.into_iter().collect::<Vec<_>>();
7717 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7718 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7719 values.extend(check_attribute_batch(cwd, batch)?);
7720 }
7721 Ok(values)
7722}
7723
7724fn check_attribute_batch(
7725 cwd: &Path,
7726 batch: &[PathBuf],
7727) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7728 const NAMES: [&str; 6] = [
7729 "filter",
7730 "working-tree-encoding",
7731 "ident",
7732 "text",
7733 "eol",
7734 "crlf",
7735 ];
7736 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7737 let mut input = String::new();
7738 for path in &paths {
7739 let path = path.to_str().ok_or_else(|| {
7740 spar_err!(
7741 "cannot inspect attributes for a non-UTF-8 path in {}",
7742 cwd.display()
7743 )
7744 })?;
7745 input.push_str(path);
7746 input.push('\0');
7747 }
7748 let argv = git_without_automation_argv(&[
7749 "check-attr",
7750 "-z",
7751 "--cached",
7752 "--stdin",
7753 "filter",
7754 "working-tree-encoding",
7755 "ident",
7756 "text",
7757 "eol",
7758 "crlf",
7759 ]);
7760 let output = proc::run_bytes(
7761 &argv,
7762 &ExecOpts::new()
7763 .cwd(cwd)
7764 .timeout_secs(30)
7765 .stdin(input)
7766 .stop_descendants(true),
7767 )?;
7768 if !output.is_empty() && !output.ends_with(&[0]) {
7769 bail!(
7770 "git returned an unterminated attribute result for {}",
7771 cwd.display()
7772 );
7773 }
7774 let fields = output
7775 .split(|byte| *byte == 0)
7776 .filter(|field| !field.is_empty())
7777 .collect::<Vec<_>>();
7778 if fields.len() != paths.len() * NAMES.len() * 3 {
7779 bail!(
7780 "git returned an unexpected attribute result for {}",
7781 cwd.display()
7782 );
7783 }
7784 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7785 for record in fields.chunks_exact(3) {
7786 let path = safe_git_path(record[0], "attribute")?;
7787 if !paths.contains(&path) {
7788 bail!(
7789 "git returned attributes for the wrong path in {}",
7790 cwd.display()
7791 );
7792 }
7793 let name = std::str::from_utf8(record[1])
7794 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7795 let value = std::str::from_utf8(record[2])
7796 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7797 values
7798 .entry(path)
7799 .or_default()
7800 .insert(name.to_string(), value.to_string());
7801 }
7802 if paths.iter().any(|path| {
7803 values
7804 .get(path)
7805 .is_none_or(|attributes| attributes.len() != NAMES.len())
7806 }) {
7807 bail!(
7808 "git omitted an attribute result for a tracked path in {}",
7809 cwd.display()
7810 );
7811 }
7812 Ok(values)
7813}
7814
7815fn attribute_is_active(value: Option<&String>) -> bool {
7816 !matches!(
7817 value.map(String::as_str),
7818 None | Some("unspecified") | Some("unset")
7819 )
7820}
7821
7822fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7823 attribute_is_active(values.get("filter"))
7824 || attribute_is_active(values.get("working-tree-encoding"))
7825}
7826
7827struct CheckoutConfig {
7836 autocrlf: Option<String>,
7837 eol: Option<String>,
7838 symlinks: Option<bool>,
7839}
7840
7841impl CheckoutConfig {
7842 fn read(cwd: &Path) -> Result<Self> {
7843 Ok(Self {
7844 autocrlf: git_config_value(cwd, "core.autocrlf")?,
7845 eol: git_config_value(cwd, "core.eol")?,
7846 symlinks: git_config_bool(cwd, "core.symlinks")?,
7847 })
7848 }
7849
7850 fn autocrlf_is_true(&self) -> bool {
7851 self.autocrlf.as_ref().is_some_and(|value| {
7852 matches!(
7853 value.to_ascii_lowercase().as_str(),
7854 "true" | "yes" | "on" | "1"
7855 )
7856 })
7857 }
7858
7859 fn eol_is(&self, wanted: &str) -> bool {
7860 self.eol
7861 .as_ref()
7862 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7863 }
7864}
7865
7866fn path_has_ambiguous_transform(
7867 config: &CheckoutConfig,
7868 values: &BTreeMap<String, String>,
7869) -> Result<bool> {
7870 if path_has_external_transform(values)
7871 || attribute_is_active(values.get("ident"))
7872 || attribute_is_active(values.get("crlf"))
7873 {
7874 return Ok(true);
7875 }
7876 let text = values.get("text").map(String::as_str);
7877 let eol = values.get("eol").map(String::as_str);
7878 if text == Some("auto") {
7879 return Ok(true);
7880 }
7881 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7882 || !matches!(
7883 eol,
7884 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7885 )
7886 {
7887 return Ok(true);
7888 }
7889 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7890 return Ok(config.autocrlf_is_true());
7891 }
7892 Ok(false)
7893}
7894
7895fn allows_expected_crlf(
7896 config: &CheckoutConfig,
7897 values: &BTreeMap<String, String>,
7898) -> Result<bool> {
7899 if path_has_external_transform(values)
7900 || attribute_is_active(values.get("ident"))
7901 || attribute_is_active(values.get("crlf"))
7902 {
7903 return Ok(false);
7904 }
7905 let text = values.get("text").map(String::as_str);
7906 let eol = values.get("eol").map(String::as_str);
7907 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7908 return Ok(false);
7909 }
7910 if eol == Some("crlf") {
7911 return Ok(true);
7912 }
7913 if text != Some("set") {
7914 return Ok(false);
7915 }
7916 if let Some(autocrlf) = config.autocrlf.as_deref() {
7917 match autocrlf.to_ascii_lowercase().as_str() {
7918 "true" | "yes" | "on" | "1" => return Ok(true),
7919 "input" => return Ok(false),
7920 _ => {}
7921 }
7922 }
7923 if config.eol_is("crlf") {
7924 return Ok(true);
7925 }
7926 #[cfg(windows)]
7927 if config.eol.is_none() || config.eol_is("native") {
7928 return Ok(true);
7929 }
7930 Ok(false)
7931}
7932
7933fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7934 let argv = git_without_automation_argv(&["config", "--get", key]);
7935 let output = proc::exec(
7936 &argv,
7937 &ExecOpts::new()
7938 .cwd(cwd)
7939 .timeout_secs(30)
7940 .check(false)
7941 .stop_descendants(true),
7942 )?;
7943 match output.code {
7944 0 => Ok(Some(output.stdout.trim().to_string())),
7945 1 => Ok(None),
7946 _ => bail!(
7947 "could not read Git configuration in {}: {}",
7948 cwd.display(),
7949 output.stderr.trim()
7950 ),
7951 }
7952}
7953
7954fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7955 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7956 let output = proc::exec(
7957 &argv,
7958 &ExecOpts::new()
7959 .cwd(cwd)
7960 .timeout_secs(30)
7961 .check(false)
7962 .stop_descendants(true),
7963 )?;
7964 match output.code {
7965 0 if output.stdout.trim() == "true" => Ok(Some(true)),
7966 0 if output.stdout.trim() == "false" => Ok(Some(false)),
7967 0 => bail!(
7968 "git returned an invalid boolean for {key} in {}",
7969 cwd.display()
7970 ),
7971 1 => Ok(None),
7972 _ => bail!(
7973 "could not read Git configuration in {}: {}",
7974 cwd.display(),
7975 output.stderr.trim()
7976 ),
7977 }
7978}
7979
7980#[cfg(unix)]
7981fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7982 use std::os::unix::fs::PermissionsExt;
7983 if metadata.permissions().mode() & 0o111 == 0 {
7984 "100644".to_string()
7985 } else {
7986 "100755".to_string()
7987 }
7988}
7989
7990#[cfg(not(unix))]
7991fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7992 "100644".to_string()
7993}
7994
7995fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7996 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7997 if !listed.is_empty() && !listed.ends_with(&[0]) {
7998 bail!(
7999 "git returned an unterminated tree listing for {}",
8000 cwd.display()
8001 );
8002 }
8003 let mut entries = Vec::new();
8004 for record in listed
8005 .split(|byte| *byte == 0)
8006 .filter(|record| !record.is_empty())
8007 {
8008 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
8009 bail!("git returned a malformed tree record for {}", cwd.display());
8010 };
8011 let fields = record[..tab]
8012 .split(|byte| *byte == b' ')
8013 .collect::<Vec<_>>();
8014 if fields.len() != 3 {
8015 bail!("git returned a malformed tree header for {}", cwd.display());
8016 }
8017 let mode = std::str::from_utf8(fields[0])
8018 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
8019 .to_string();
8020 let oid = std::str::from_utf8(fields[2])
8021 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
8022 .to_string();
8023 entries.push(IndexEntry {
8024 path: safe_git_path(&record[tab + 1..], "tree")?,
8025 mode,
8026 oid,
8027 });
8028 }
8029 Ok(entries)
8030}
8031
8032fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
8033 Ok(tree_entries(cwd, "HEAD")?
8034 .into_iter()
8035 .filter(|entry| entry.mode == "160000")
8036 .map(|entry| (entry.path, entry.oid))
8037 .collect())
8038}
8039
8040fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
8041 let head = head_gitlinks(cwd)?;
8042 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
8043 .into_iter()
8044 .map(|link| (link.path, link.oid))
8045 .collect();
8046 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
8047 paths.extend(index.keys().cloned());
8048 Ok(paths
8049 .into_iter()
8050 .filter(|path| head.get(path) != index.get(path))
8051 .collect())
8052}
8053
8054fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
8055 let path = parent.join(relative);
8056 let metadata = match std::fs::symlink_metadata(&path) {
8057 Ok(metadata) => metadata,
8058 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8059 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8060 };
8061 if !metadata.is_dir() {
8062 bail!("the gitlink at {} is not a directory", path.display());
8063 }
8064 let canonical = std::fs::canonicalize(&path)
8065 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
8066 if canonical != path {
8067 bail!(
8068 "the gitlink at {} resolves through a symlink",
8069 path.display()
8070 );
8071 }
8072 if !path.join(".git").exists() {
8073 let empty = std::fs::read_dir(&path)
8074 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
8075 .next()
8076 .is_none();
8077 if empty {
8078 return Ok(None);
8079 }
8080 bail!(
8081 "the uninitialized gitlink at {} contains local files",
8082 path.display()
8083 );
8084 }
8085 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
8086 if inside.trim() != "true" {
8087 bail!("the gitlink at {} is not a worktree", path.display());
8088 }
8089 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
8090 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
8091 spar_err!(
8092 "could not resolve the gitlink top level at {}: {e}",
8093 path.display()
8094 )
8095 })?;
8096 if top != canonical {
8097 bail!(
8098 "the gitlink at {} belongs to a different worktree",
8099 path.display()
8100 );
8101 }
8102 Ok(Some(canonical))
8103}
8104
8105fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
8119 let root = std::fs::canonicalize(cwd)
8120 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8121 let mut allowed = BTreeSet::from([root.join(".git")]);
8122 let mut checkouts = BTreeSet::new();
8123 let mut repositories = vec![root.clone()];
8124 let mut visited = BTreeSet::new();
8125 while let Some(repository) = repositories.pop() {
8126 let canonical = std::fs::canonicalize(&repository)
8127 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8128 if !visited.insert(canonical.clone()) {
8129 bail!("submodule recursion revisited {}", canonical.display());
8130 }
8131 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
8132 let (path, nested) = untracked_record(&raw, "untracked")?;
8133 if nested {
8134 checkouts.insert(canonical.join(path));
8135 }
8136 }
8137 for link in gitlinks(&canonical)? {
8138 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
8139 continue;
8140 };
8141 allowed.insert(submodule.join(".git"));
8142 repositories.push(submodule);
8143 }
8144 }
8145
8146 let scan_root = root.clone();
8150 let mut directories = vec![root];
8151 while let Some(directory) = directories.pop() {
8152 let entries = match std::fs::read_dir(&directory) {
8153 Ok(entries) => entries,
8154 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8155 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8156 };
8157 for entry in entries {
8158 let entry = match entry {
8159 Ok(entry) => entry,
8160 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8161 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8162 };
8163 let path = entry.path();
8164 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
8165 continue;
8166 }
8167 if entry.file_name() == OsStr::new(".git") {
8168 if !allowed.contains(&path) {
8169 return Ok(Some(path));
8170 }
8171 continue;
8172 }
8173 let kind = match entry.file_type() {
8174 Ok(kind) => kind,
8175 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8176 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8177 };
8178 if kind.is_dir() && !checkouts.contains(&path) {
8179 directories.push(path);
8180 }
8181 }
8182 }
8183 Ok(None)
8184}
8185
8186pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
8187 if let Some(path) = unexpected_nested_git_entry(cwd)? {
8188 bail!(
8189 "the worktree contains a Git entry at {} that Git does not report as a nested \
8190 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
8191 prove they are unchanged.",
8192 path.display()
8193 );
8194 }
8195 let root = std::fs::canonicalize(cwd)
8196 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8197 let mut repositories = BTreeMap::new();
8198 let mut visited = BTreeSet::new();
8199 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
8200 Ok(GitState { repositories })
8201}
8202
8203fn collect_git_state(
8204 repository: &Path,
8205 prefix: &Path,
8206 visited: &mut BTreeSet<PathBuf>,
8207 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
8208) -> Result<()> {
8209 let canonical = std::fs::canonicalize(repository)
8210 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8211 if !visited.insert(canonical.clone()) {
8212 bail!("submodule recursion revisited {}", canonical.display());
8213 }
8214 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8215 let head = head.trim().to_string();
8216 if head.is_empty() {
8217 bail!("git returned an empty head for {}", repository.display());
8218 }
8219 let unsafe_index_flags = unsafe_index_flags(repository)?;
8220 let tracked = tracked_entries(repository)?;
8221 let gitlinks = gitlinks(repository)?;
8222 if repositories
8223 .insert(
8224 prefix.to_path_buf(),
8225 RepositoryState {
8226 head,
8227 unsafe_index_flags,
8228 tracked,
8229 gitlinks: gitlinks
8230 .iter()
8231 .map(|link| (link.path.clone(), link.oid.clone()))
8232 .collect(),
8233 },
8234 )
8235 .is_some()
8236 {
8237 bail!("Git state contains duplicate repository path {:?}", prefix);
8238 }
8239
8240 for link in gitlinks {
8241 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
8242 continue;
8243 };
8244 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
8245 }
8246 Ok(())
8247}
8248
8249fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
8250 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
8251 if !listed.is_empty() && !listed.ends_with(&[0]) {
8252 bail!(
8253 "git returned an unterminated index-flag listing for {}",
8254 cwd.display()
8255 );
8256 }
8257 let mut unsafe_records = Vec::new();
8258 for record in listed
8259 .split(|byte| *byte == 0)
8260 .filter(|record| !record.is_empty())
8261 {
8262 if record.len() < 3 || record[1] != b' ' {
8263 bail!(
8264 "git returned a malformed index-flag record for {}",
8265 cwd.display()
8266 );
8267 }
8268 if record[0] != b'H' {
8269 unsafe_records.extend_from_slice(record);
8270 unsafe_records.push(0);
8271 }
8272 }
8273 Ok(unsafe_records)
8274}
8275
8276pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
8277 safe_git_state(cwd).map(|_| ())
8278}
8279
8280pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
8281 let state = git_state(cwd)?;
8282 if let Some((path, _repository)) = state
8283 .repositories
8284 .iter()
8285 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
8286 {
8287 let label = if path.as_os_str().is_empty() {
8288 cwd.to_path_buf()
8289 } else {
8290 cwd.join(path)
8291 };
8292 bail!(
8293 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
8294 SPAR cannot prove the working files are unchanged, so it was kept.",
8295 label.display()
8296 );
8297 }
8298 Ok(state)
8299}
8300
8301fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
8302 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
8303}
8304
8305fn repository_has_recoverable_work_with(
8306 cwd: &Path,
8307 include_ignored: bool,
8308 disposable: Disposable,
8309) -> Result<bool> {
8310 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
8311 return Ok(true);
8312 }
8313 let mut visited = BTreeSet::new();
8314 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
8315}
8316
8317fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
8318 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
8319 let git_dir = PathBuf::from(git_dir.trim());
8320 let git_dir = if git_dir.is_absolute() {
8321 git_dir
8322 } else {
8323 cwd.join(git_dir)
8324 };
8325 let git_dir = std::fs::canonicalize(&git_dir)
8326 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8327 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8328 Ok(_) => return Ok(true),
8329 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8330 Err(error) => {
8331 return Err(spar_err!(
8332 "could not inspect per-worktree configuration in {}: {error}",
8333 git_dir.display()
8334 ))
8335 }
8336 }
8337
8338 let orig_head = git_dir.join("ORIG_HEAD");
8339 match std::fs::symlink_metadata(&orig_head) {
8340 Ok(metadata) if metadata.is_file() => {
8341 let oid = std::fs::read_to_string(&orig_head)
8342 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8343 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8344 return Ok(true);
8345 };
8346 if !commit_has_shared_ref(cwd, &commit)? {
8347 return Ok(true);
8348 }
8349 }
8350 Ok(_) => return Ok(true),
8351 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8352 Err(error) => {
8353 return Err(spar_err!(
8354 "could not inspect {}: {error}",
8355 orig_head.display()
8356 ))
8357 }
8358 }
8359
8360 let edit_message = git_dir.join("COMMIT_EDITMSG");
8361 match std::fs::symlink_metadata(&edit_message) {
8362 Ok(metadata) if metadata.is_file() => {
8363 let draft = std::fs::read(&edit_message)
8364 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8365 if draft != head_commit_message(cwd)? {
8366 return Ok(true);
8367 }
8368 }
8369 Ok(_) => return Ok(true),
8370 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8371 Err(error) => {
8372 return Err(spar_err!(
8373 "could not inspect {}: {error}",
8374 edit_message.display()
8375 ))
8376 }
8377 }
8378
8379 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8380 return Ok(true);
8381 }
8382
8383 let local_refs = run_git_bytes(
8384 cwd,
8385 &[
8386 "for-each-ref",
8387 "--format=%(refname)",
8388 "refs/worktree",
8389 "refs/bisect",
8390 "refs/rewritten",
8391 ],
8392 )?;
8393 if !local_refs.is_empty() {
8394 return Ok(true);
8395 }
8396
8397 for entry in std::fs::read_dir(&git_dir)
8401 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8402 {
8403 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8404 let known = matches!(
8405 entry.file_name().to_str(),
8406 Some(
8407 "HEAD"
8408 | "ORIG_HEAD"
8409 | "COMMIT_EDITMSG"
8410 | "FETCH_HEAD"
8411 | "commondir"
8412 | "gitdir"
8413 | "index"
8414 | "logs"
8415 | "refs"
8416 )
8417 );
8418 if !known {
8419 return Ok(true);
8420 }
8421 }
8422
8423 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8424 if !commit_has_shared_ref(cwd, head.trim())? {
8425 return Ok(true);
8426 }
8427 Ok(false)
8428}
8429
8430fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8431 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8432 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8433 bail!(
8434 "git returned a commit without a message separator in {}",
8435 cwd.display()
8436 );
8437 };
8438 Ok(commit[split + 2..].to_vec())
8439}
8440
8441fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8442 let metadata = match std::fs::symlink_metadata(logs) {
8443 Ok(metadata) => metadata,
8444 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8445 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8446 };
8447 if !metadata.is_dir() {
8448 return Ok(true);
8449 }
8450 let mut files = Vec::new();
8451 let mut directories = vec![logs.to_path_buf()];
8452 while let Some(directory) = directories.pop() {
8453 for entry in std::fs::read_dir(&directory)
8454 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8455 {
8456 let entry =
8457 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8458 let path = entry.path();
8459 let kind = entry
8460 .file_type()
8461 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8462 if kind.is_dir() {
8463 directories.push(path);
8464 } else if kind.is_file() {
8465 files.push(path);
8466 } else {
8467 return Ok(true);
8468 }
8469 }
8470 }
8471
8472 let mut commits = BTreeSet::new();
8473 for path in files {
8474 if !collect_reflog_commits(cwd, &path, &mut commits)? {
8475 return Ok(true);
8476 }
8477 }
8478 for commit in commits {
8479 if !commit_has_shared_ref(cwd, &commit)? {
8480 return Ok(true);
8481 }
8482 }
8483 Ok(false)
8484}
8485
8486fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8490 let common = common_git_dir(cwd)?;
8491 let reflog = common.join("logs").join(refname);
8492 let metadata = match std::fs::symlink_metadata(&reflog) {
8493 Ok(metadata) => metadata,
8494 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8495 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8496 };
8497 if !metadata.is_file() {
8498 return Ok(false);
8499 }
8500 let mut commits = BTreeSet::new();
8501 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8502 return Ok(false);
8503 }
8504 for commit in commits {
8505 if is_ancestor(cwd, &commit, durable_tip)?
8506 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8507 {
8508 continue;
8509 }
8510 return Ok(false);
8511 }
8512 Ok(true)
8513}
8514
8515fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8516 let file = std::fs::File::open(path)
8517 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8518 for line in std::io::BufReader::new(file).lines() {
8519 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8520 let mut fields = line.splitn(3, ' ');
8521 let Some(old) = fields.next() else {
8522 return Ok(false);
8523 };
8524 let Some(new) = fields.next() else {
8525 return Ok(false);
8526 };
8527 if fields.next().is_none() {
8528 return Ok(false);
8529 }
8530 for oid in [old, new] {
8531 if oid.bytes().all(|byte| byte == b'0') {
8532 continue;
8533 }
8534 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8535 return Ok(false);
8536 };
8537 commits.insert(commit);
8538 }
8539 }
8540 Ok(true)
8541}
8542
8543fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8544 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8545 let path = PathBuf::from(raw.trim());
8546 let path = if path.is_absolute() {
8547 path
8548 } else {
8549 cwd.join(path)
8550 };
8551 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8552}
8553
8554fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8555 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8556 let output = proc::exec(
8557 &argv,
8558 &ExecOpts::new()
8559 .cwd(cwd)
8560 .timeout_secs(30)
8561 .check(false)
8562 .stop_descendants(true),
8563 )?;
8564 match output.code {
8565 0 => Ok(true),
8566 1 => Ok(false),
8567 _ => bail!("{}", proc::failure_message(&argv, &output)),
8568 }
8569}
8570
8571fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8572 let commit = format!("{oid}^{{commit}}");
8573 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8574 let output = proc::exec(
8575 &argv,
8576 &ExecOpts::new()
8577 .cwd(cwd)
8578 .timeout_secs(30)
8579 .check(false)
8580 .stop_descendants(true),
8581 )?;
8582 if output.code != 0 {
8583 return Ok(None);
8584 }
8585 let oid = output.stdout.trim();
8586 if oid.is_empty() {
8587 return Ok(None);
8588 }
8589 Ok(Some(oid.to_string()))
8590}
8591
8592fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8593 commit_has_shared_ref_except(cwd, oid, None)
8594}
8595
8596fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8597 let contains = format!("--contains={oid}");
8598 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8599 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8600 !record.is_empty()
8601 && !record.starts_with(b"refs/worktree/")
8602 && !record.starts_with(b"refs/bisect/")
8603 && !record.starts_with(b"refs/rewritten/")
8604 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8605 }))
8606}
8607
8608fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8623 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8624 for raw in &ordinary {
8625 let (path, _) = untracked_record(raw, "untracked")?;
8626 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8627 continue;
8628 }
8629 return Ok(true);
8630 }
8631 let listed = untracked_listing(
8632 cwd,
8633 &[
8634 "ls-files",
8635 "--others",
8636 "--ignored",
8637 "--exclude-standard",
8638 "-z",
8639 ],
8640 )?;
8641 for raw in listed {
8642 let (path, nested) = untracked_record(&raw, "ignored")?;
8643 if nested {
8647 return Ok(true);
8648 }
8649 if disposable == Disposable::BuildOutput
8650 && !is_generated_artifact(&path)
8651 && !is_desktop_metadata(&path)
8652 {
8653 return Ok(true);
8654 }
8655 }
8656 Ok(false)
8657}
8658
8659fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8660 let listed = run_git_bytes(cwd, args)?;
8661 if !listed.is_empty() && !listed.ends_with(&[0]) {
8662 bail!(
8663 "git returned an unterminated untracked-file list for {}",
8664 cwd.display()
8665 );
8666 }
8667 Ok(listed
8668 .split(|byte| *byte == 0)
8669 .filter(|raw| !raw.is_empty())
8670 .map(|raw| raw.to_vec())
8671 .collect())
8672}
8673
8674fn repository_has_recoverable_work_inner(
8675 cwd: &Path,
8676 include_ignored: bool,
8677 disposable: Disposable,
8678 visited: &mut BTreeSet<PathBuf>,
8679) -> Result<bool> {
8680 let canonical = std::fs::canonicalize(cwd)
8681 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8682 if !visited.insert(canonical.clone()) {
8683 bail!("submodule recursion revisited {}", canonical.display());
8684 }
8685 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8686 return Ok(true);
8687 }
8688 if !unsafe_index_flags(cwd)?.is_empty() {
8689 return Ok(true);
8690 }
8691 if attributes_may_be_modified(cwd)? {
8692 return Ok(true);
8693 }
8694 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8695 return Ok(true);
8696 }
8697 if include_ignored {
8698 let index = index_entries(cwd)?
8699 .into_iter()
8700 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8701 .collect::<BTreeMap<_, _>>();
8702 let head = tree_entries(cwd, "HEAD")?
8703 .into_iter()
8704 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8705 .collect::<BTreeMap<_, _>>();
8706 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8707 return Ok(true);
8708 }
8709 let tracked = tracked_entries(cwd)?;
8710 let effective = check_attributes(cwd, tracked.keys().cloned())?;
8711 let config = CheckoutConfig::read(cwd)?;
8712 for (path, entry) in tracked {
8713 let Some(worktree) = entry.worktree else {
8714 return Ok(true);
8715 };
8716 let attributes = effective.get(&path).ok_or_else(|| {
8717 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8718 })?;
8719 if path_has_ambiguous_transform(&config, attributes)? {
8720 return Ok(true);
8721 }
8722 let symlink_file = entry.index_mode == "120000"
8723 && worktree.mode == "100644"
8724 && worktree.raw_oid == entry.index_oid
8725 && config.symlinks == Some(false);
8726 if worktree.mode != entry.index_mode && !symlink_file {
8727 return Ok(true);
8728 }
8729 if entry.index_mode == "120000" {
8730 if worktree.raw_oid != entry.index_oid {
8731 return Ok(true);
8732 }
8733 continue;
8734 }
8735 #[cfg(unix)]
8736 {
8737 let expected = if entry.index_mode == "100755" {
8738 0o755
8739 } else {
8740 0o644
8741 };
8742 if worktree.permissions != expected {
8743 return Ok(true);
8744 }
8745 }
8746 if allows_expected_crlf(&config, attributes)? {
8747 let (normalized, every_lf_was_crlf) =
8748 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8749 if !every_lf_was_crlf || normalized != entry.index_oid {
8750 return Ok(true);
8751 }
8752 } else if worktree.raw_oid != entry.index_oid {
8753 return Ok(true);
8754 }
8755 }
8756 } else {
8757 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8758 if !run_git_bytes(cwd, &args)?.is_empty() {
8759 return Ok(true);
8760 }
8761 }
8762 for link in gitlinks(cwd)? {
8763 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8764 continue;
8765 };
8766 if include_ignored {
8771 return Ok(true);
8772 }
8773 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8774 if head.trim() != link.oid {
8775 return Ok(true);
8776 }
8777 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8778 {
8779 return Ok(true);
8780 }
8781 }
8782 Ok(false)
8783}
8784
8785pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8786 repository_has_recoverable_work(cwd, false)
8787}
8788
8789fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8790 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8791 Ok(!run_git_bytes(cwd, &args)?.is_empty())
8792}
8793
8794#[cfg(unix)]
8795fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8796 use std::os::unix::ffi::OsStringExt;
8797 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8798}
8799
8800#[cfg(not(unix))]
8801fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8802 String::from_utf8(raw.to_vec())
8803 .map(PathBuf::from)
8804 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8805}
8806
8807fn vanished_untracked_file() -> UntrackedFile {
8823 UntrackedFile {
8824 kind: 0,
8825 len: 0,
8826 modified: None,
8827 created: None,
8828 readonly: false,
8829 symlink_target: None,
8830 #[cfg(unix)]
8831 device: 0,
8832 #[cfg(unix)]
8833 inode: 0,
8834 #[cfg(unix)]
8835 mode: 0,
8836 #[cfg(unix)]
8837 change_seconds: 0,
8838 #[cfg(unix)]
8839 change_nanoseconds: 0,
8840 }
8841}
8842
8843fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8844 let metadata = match std::fs::symlink_metadata(path) {
8845 Ok(metadata) => metadata,
8846 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8847 Err(e) => {
8848 return Err(spar_err!(
8849 "could not inspect the nested repository at {}: {e}",
8850 path.display()
8851 ))
8852 }
8853 };
8854 if !metadata.is_dir() {
8855 bail!(
8856 "git reported {} as a nested repository, but it is not a directory",
8857 path.display()
8858 );
8859 }
8860 if std::fs::symlink_metadata(path.join(".git")).is_err() {
8861 bail!(
8862 "git reported {} as a nested repository, but it has no Git entry",
8863 path.display()
8864 );
8865 }
8866 #[cfg(unix)]
8867 {
8868 use std::os::unix::fs::MetadataExt;
8869 Ok(UntrackedFile {
8870 kind: 3,
8871 len: 0,
8872 modified: None,
8873 created: metadata.created().ok(),
8874 readonly: metadata.permissions().readonly(),
8875 symlink_target: None,
8876 device: metadata.dev(),
8877 inode: metadata.ino(),
8878 mode: metadata.mode(),
8879 change_seconds: 0,
8880 change_nanoseconds: 0,
8881 })
8882 }
8883 #[cfg(not(unix))]
8884 {
8885 Ok(UntrackedFile {
8886 kind: 3,
8887 len: 0,
8888 modified: None,
8889 created: metadata.created().ok(),
8890 readonly: metadata.permissions().readonly(),
8891 symlink_target: None,
8892 })
8893 }
8894}
8895
8896fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8897 let metadata = match std::fs::symlink_metadata(path) {
8898 Ok(metadata) => metadata,
8899 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8900 Err(e) => {
8901 return Err(spar_err!(
8902 "could not inspect untracked file {}: {e}",
8903 path.display()
8904 ))
8905 }
8906 };
8907 let kind = if metadata.file_type().is_symlink() {
8908 2
8909 } else if metadata.is_file() {
8910 1
8911 } else {
8912 bail!(
8913 "untracked path {} is not a regular file or symlink",
8914 path.display()
8915 );
8916 };
8917 let symlink_target = if kind == 2 {
8918 match std::fs::read_link(path) {
8919 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8920 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8921 return Ok(vanished_untracked_file())
8922 }
8923 Err(e) => {
8924 return Err(spar_err!(
8925 "could not read untracked symlink {}: {e}",
8926 path.display()
8927 ))
8928 }
8929 }
8930 } else {
8931 None
8932 };
8933 #[cfg(unix)]
8934 {
8935 use std::os::unix::fs::MetadataExt;
8936 Ok(UntrackedFile {
8937 kind,
8938 len: metadata.len(),
8939 modified: metadata.modified().ok(),
8940 created: metadata.created().ok(),
8941 readonly: metadata.permissions().readonly(),
8942 symlink_target,
8943 device: metadata.dev(),
8944 inode: metadata.ino(),
8945 mode: metadata.mode(),
8946 change_seconds: metadata.ctime(),
8947 change_nanoseconds: metadata.ctime_nsec(),
8948 })
8949 }
8950 #[cfg(not(unix))]
8951 {
8952 Ok(UntrackedFile {
8953 kind,
8954 len: metadata.len(),
8955 modified: metadata.modified().ok(),
8956 created: metadata.created().ok(),
8957 readonly: metadata.permissions().readonly(),
8958 symlink_target,
8959 })
8960 }
8961}
8962
8963#[cfg(unix)]
8964fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8965 use std::os::unix::ffi::OsStrExt;
8966 Ok(value.as_bytes().to_vec())
8967}
8968
8969#[cfg(not(unix))]
8970fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8971 value
8972 .to_str()
8973 .map(|value| value.as_bytes().to_vec())
8974 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8975}
8976
8977#[cfg(unix)]
8978fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8979 use std::os::unix::fs::MetadataExt;
8980 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8981}
8982
8983#[cfg(not(unix))]
8984fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8985 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8986}
8987
8988#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8989pub struct BranchRecord {
8990 pub kind: String,
8991 pub number: i64,
8992}
8993
8994pub fn review_ref(number: i64) -> String {
8997 format!("refs/spar/pr-{number}")
8998}
8999
9000pub fn is_finished(state: &str) -> bool {
9001 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
9002}
9003
9004pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
9011 if let Some(parent) = path.parent() {
9012 std::fs::create_dir_all(parent)
9013 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
9014 }
9015 let tmp = path.with_extension(format!(
9018 "{}.tmp",
9019 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
9020 ));
9021 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
9022 std::fs::rename(&tmp, path)
9023 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
9024 Ok(())
9025}
9026
9027pub fn has_heading(text: &str, title: &str) -> bool {
9034 let wanted = title.trim().to_lowercase();
9035 if wanted.is_empty() {
9036 return false;
9037 }
9038 text.lines()
9039 .filter_map(|line| line.trim().strip_prefix("## "))
9040 .any(|heading| heading.trim().to_lowercase() == wanted)
9041}
9042
9043pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
9046 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
9047}
9048
9049#[derive(Deserialize)]
9051#[serde(rename_all = "camelCase")]
9052pub struct LinkedPrRow {
9053 number: i64,
9054 #[serde(default)]
9055 url: String,
9056 #[serde(default)]
9057 title: String,
9058 #[serde(default)]
9059 closing_issues_references: Vec<IssueRef>,
9060}
9061
9062pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
9069 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
9070}
9071
9072pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
9074 rows.iter()
9075 .find(|row| {
9076 row.closing_issues_references
9077 .iter()
9078 .any(|linked| linked.number == issue)
9079 })
9080 .map(|row| PrRef {
9081 number: row.number,
9082 url: row.url.clone(),
9083 title: row.title.clone(),
9084 })
9085}
9086
9087pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
9089 pick_linked_pr(&linked_pr_rows(json)?, issue)
9090}
9091
9092pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
9099 if text.trim().is_empty() {
9100 return Err(spar_err!("GitHub returned no comment data"));
9101 }
9102 let mut out = Vec::new();
9103 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9104 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
9105 Value::Array(items) => out.extend(items),
9106 _ => return Err(spar_err!("unexpected non-array comment page")),
9107 }
9108 }
9109 Ok(out)
9110}
9111
9112pub fn parse_comment_pages(text: &str) -> Vec<Value> {
9113 let mut out = Vec::new();
9114 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9115 match value {
9116 Ok(Value::Array(items)) => out.extend(items),
9117 Ok(other) => out.push(other),
9118 Err(_) => break,
9119 }
9120 }
9121 out
9122}
9123
9124pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
9132 body.trim_start().starts_with(STATE_MARKER)
9133 && !viewer.trim().is_empty()
9134 && author.trim().eq_ignore_ascii_case(viewer.trim())
9135 && parse_state_comment(body).is_some()
9136}
9137
9138pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
9141 let marker = body.find(STATE_MARKER)?;
9142 let start = body[marker..].find('{')? + marker;
9143 let end = body.rfind('}')?;
9144 if end <= start {
9145 return None;
9146 }
9147 match serde_json::from_str(&body[start..=end]) {
9148 Ok(state) => Some(state),
9149 Err(_) => {
9150 logdim!("found a spar state comment but could not parse it");
9151 None
9152 }
9153 }
9154}
9155
9156fn choose_state_for_head(
9157 candidates: Vec<PersistedState>,
9158 actual_head: &str,
9159) -> Option<PersistedState> {
9160 let matching: Vec<PersistedState> = candidates
9161 .iter()
9162 .filter(|state| state.pr_head == actual_head)
9163 .cloned()
9164 .collect();
9165 if !matching.is_empty() {
9166 return newest_state(matching);
9167 }
9168 newest_state(candidates)
9169}
9170
9171fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
9172 candidates.into_iter().reduce(|best, candidate| {
9173 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
9174 candidate
9175 } else {
9176 best
9181 }
9182 })
9183}
9184
9185pub fn self_binary() -> Result<PathBuf> {
9191 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
9192 let path = PathBuf::from(path);
9193 if proc::is_executable(&path) {
9194 return Ok(path);
9195 }
9196 bail!(
9197 "SPAR_SELF_BIN is set to {}, which is not executable",
9198 path.display()
9199 );
9200 }
9201 std::env::current_exe()
9202 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
9203}
9204
9205fn bool_env(value: bool) -> &'static str {
9206 if value {
9207 "1"
9208 } else {
9209 "0"
9210 }
9211}
9212
9213pub fn sh_quote(text: &str) -> String {
9216 format!("'{}'", text.replace('\'', r"'\''"))
9217}
9218
9219pub fn style_from_env() -> Style {
9222 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
9223 Style {
9224 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
9225 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
9226 ..Style::permissive()
9227 }
9228}
9229
9230#[cfg(test)]
9231mod tests {
9232 use super::*;
9233 use crate::config::StateStore;
9234 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
9235 use std::process::Command;
9236
9237 fn repo_for_titles() -> Repo {
9238 Repo {
9239 root: PathBuf::from("/nonexistent"),
9240 style: Style::default(),
9241 branch_prefix: String::new(),
9242 state_store: StateStore::Local,
9243 followups: crate::config::Followups::Issues,
9244 drafts: Drafts::Never,
9245 fork_when_denied: true,
9246 setup: CheckCommand::default(),
9247 isolate_reads: true,
9248 keep_worktrees: false,
9249 fork: OnceLock::new(),
9250 viewer: OnceLock::new(),
9251 checkpoints: Mutex::new(BTreeMap::new()),
9252 writes: WriteStats::default(),
9253 }
9254 }
9255
9256 #[test]
9259 fn only_a_read_checkout_name_names_its_owner() {
9260 assert_eq!(Some(4242), read_checkout_owner("read-4242-0"));
9261 assert_eq!(None, read_checkout_owner("read-4242"));
9262 assert_eq!(None, read_checkout_owner("read-me-0"));
9263 assert_eq!(None, read_checkout_owner("read-4242-x"));
9264 assert_eq!(None, read_checkout_owner("review-4242"));
9265 }
9266
9267 #[test]
9268 fn write_results_accumulate_for_the_run() {
9269 let repo = repo_for_titles();
9270
9271 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
9272 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
9273
9274 assert_eq!(
9275 WriteSummary {
9276 attempted: 2,
9277 failed: 1,
9278 },
9279 repo.write_summary()
9280 );
9281 }
9282
9283 #[test]
9284 fn only_failed_write_preflights_join_the_summary() {
9285 let repo = repo_for_titles();
9286
9287 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
9288 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
9289
9290 assert_eq!(
9291 WriteSummary {
9292 attempted: 1,
9293 failed: 1,
9294 },
9295 repo.write_summary()
9296 );
9297 }
9298
9299 #[test]
9300 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
9301 let repo = repo_for_titles();
9302
9303 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
9304 assert_eq!(
9305 WriteSummary {
9306 attempted: 1,
9307 failed: 1,
9308 },
9309 repo.write_summary()
9310 );
9311 }
9312
9313 #[test]
9314 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
9315 let mut repo = repo_for_titles();
9316 repo.followups = Followups::Local;
9317
9318 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
9319 assert_eq!(WriteSummary::default(), repo.write_summary());
9320 }
9321
9322 #[test]
9323 fn a_failed_remote_state_read_stops_before_state_mutation() {
9324 let root = std::env::temp_dir().join(format!(
9325 "spar-state-preflight-{}-{}",
9326 std::process::id(),
9327 std::time::SystemTime::now()
9328 .duration_since(std::time::UNIX_EPOCH)
9329 .unwrap()
9330 .as_nanos()
9331 ));
9332 std::fs::create_dir_all(&root).unwrap();
9333 let _fixture = ReviewFixture { root: root.clone() };
9334 let mut repo = repo_for_titles();
9335 repo.root = root;
9336 repo.state_store = StateStore::Both;
9337 let state = PersistedState {
9338 version: 1,
9339 checkpoint: 4,
9340 round: 2,
9341 next_actor: "a".into(),
9342 status: Status::Pending,
9343 pr_head: "abc123".into(),
9344 ledger: Ledger::new(),
9345 filed: Vec::new(),
9346 open_findings: Vec::new(),
9347 disputes: Vec::new(),
9348 noted: Vec::new(),
9349 };
9350
9351 let error = repo
9352 .write_state_after_remote_read(
9353 7,
9354 &state,
9355 Err(crate::error::SparError::new("state comments unavailable")),
9356 )
9357 .unwrap_err();
9358
9359 assert!(error.to_string().contains("state comments unavailable"));
9360 assert!(!repo.state_path(7).exists());
9361 assert_eq!(0, repo.remembered_checkpoint(7));
9362 assert_eq!(
9363 WriteSummary {
9364 attempted: 1,
9365 failed: 1,
9366 },
9367 repo.write_summary()
9368 );
9369 }
9370
9371 #[test]
9372 fn only_known_build_and_cache_directories_are_generated_artifacts() {
9373 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9374 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9375 assert!(is_generated_artifact(Path::new(
9376 "package/node_modules/dependency/file.js"
9377 )));
9378 assert!(!is_generated_artifact(Path::new(
9379 "distribution/required-package.js"
9380 )));
9381 assert!(!is_generated_artifact(Path::new(
9382 "generated/required-fixture.txt"
9383 )));
9384 assert!(!is_generated_artifact(Path::new("local.env")));
9385 }
9386
9387 #[test]
9388 fn only_finder_state_files_are_desktop_metadata() {
9389 assert!(is_desktop_metadata(Path::new(".DS_Store")));
9390 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9391 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9392 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9393 }
9394
9395 struct ReviewFixture {
9396 root: PathBuf,
9397 }
9398
9399 impl Drop for ReviewFixture {
9400 fn drop(&mut self) {
9401 let _ = std::fs::remove_dir_all(&self.root);
9402 }
9403 }
9404
9405 fn test_git(cwd: &Path, args: &[&str]) -> String {
9406 let output = Command::new("git")
9407 .args(args)
9408 .current_dir(cwd)
9409 .output()
9410 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9411 assert!(
9412 output.status.success(),
9413 "git {args:?} failed: {}",
9414 String::from_utf8_lossy(&output.stderr)
9415 );
9416 String::from_utf8_lossy(&output.stdout).into_owned()
9417 }
9418
9419 #[test]
9423 fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9424 let root =
9425 std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9426 let _cleanup = ReviewFixture { root: root.clone() };
9427 let origin = root.join("origin.git");
9428 let work = root.join("work");
9429 let marker = root.join("textconv-ran");
9430 std::fs::create_dir_all(&origin).unwrap();
9431 std::fs::create_dir_all(&work).unwrap();
9432 test_git(&origin, &["init", "--bare", "-b", "main"]);
9433 test_git(&work, &["init", "-b", "main"]);
9434 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9435 test_git(&work, &["config", "user.name", "spar test"]);
9436 test_git(&work, &["config", "commit.gpgsign", "false"]);
9437 test_git(&work, &["config", "diff.noprefix", "true"]);
9438 test_git(&work, &["config", "diff.context", "0"]);
9439 test_git(&work, &["config", "diff.interHunkContext", "10"]);
9440 test_git(&work, &["config", "diff.algorithm", "patience"]);
9441 test_git(&work, &["config", "color.diff", "always"]);
9442 test_git(
9443 &work,
9444 &[
9445 "config",
9446 "diff.conv.textconv",
9447 &format!("touch {}; cat", marker.display()),
9448 ],
9449 );
9450 std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9451 let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9452 std::fs::write(work.join("src.txt"), &lines).unwrap();
9453 test_git(&work, &["add", "."]);
9454 test_git(&work, &["commit", "-m", "seed"]);
9455 test_git(
9456 &work,
9457 &["remote", "add", "origin", origin.to_str().unwrap()],
9458 );
9459 test_git(&work, &["push", "-u", "origin", "main"]);
9460 test_git(&work, &["checkout", "-b", "feature"]);
9461 std::fs::write(
9462 work.join("src.txt"),
9463 lines
9464 .replace("line 5\n", "changed 5\n")
9465 .replace("line 15\n", "changed 15\n"),
9466 )
9467 .unwrap();
9468 std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9469 std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9470 test_git(&work, &["add", "."]);
9471 test_git(&work, &["commit", "-m", "change"]);
9472 let cfg = crate::config::parse(
9473 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9474 )
9475 .unwrap();
9476 let repo = Repo::open(&work, &cfg).unwrap();
9477 assert_eq!(
9478 Hunks::from([
9479 ("new.txt".to_string(), vec![(1, 2)]),
9480 ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9481 ("with space.txt".to_string(), vec![(1, 1)]),
9482 ]),
9483 repo.diff_hunks(&work, "main")
9484 );
9485 assert!(!marker.exists(), "the text converter ran");
9486 test_git(&work, &["diff", "origin/main...HEAD"]);
9488 assert!(marker.exists(), "the fixture's converter never runs at all");
9489 }
9490
9491 fn review_fixture(
9492 tag: &str,
9493 number: i64,
9494 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9495 use std::sync::atomic::{AtomicU32, Ordering};
9496 static NEXT: AtomicU32 = AtomicU32::new(0);
9497 let id = NEXT.fetch_add(1, Ordering::Relaxed);
9498 let root =
9499 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9500 let origin = root.join("origin.git");
9501 let work = root.join("work");
9502 std::fs::create_dir_all(&origin).unwrap();
9503 std::fs::create_dir_all(&work).unwrap();
9504 test_git(&origin, &["init", "--bare", "-b", "main"]);
9505 test_git(&work, &["init", "-b", "main"]);
9506 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9507 test_git(&work, &["config", "user.name", "spar test"]);
9508 test_git(&work, &["config", "commit.gpgsign", "false"]);
9509 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9510 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9511 std::fs::write(work.join("README.md"), "seed\n").unwrap();
9512 std::fs::write(work.join("data.txt"), "old\n").unwrap();
9513 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9514 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9515 test_git(&work, &["add", "."]);
9516 test_git(&work, &["commit", "-m", "seed"]);
9517 test_git(
9518 &work,
9519 &["remote", "add", "origin", origin.to_str().unwrap()],
9520 );
9521 test_git(&work, &["push", "-u", "origin", "main"]);
9522 test_git(
9523 &work,
9524 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9525 );
9526 let cfg = crate::config::parse(
9527 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9528 )
9529 .unwrap();
9530 let repo = Repo::open(&work, &cfg).unwrap();
9531 let path = repo.worktree_for_pr_head(number).unwrap();
9532 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9533 (ReviewFixture { root }, repo, path, checkpoint)
9534 }
9535
9536 #[test]
9537 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9538 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9539
9540 repo.release_review_worktree_checked(901, &checkpoint)
9541 .unwrap();
9542
9543 assert!(!path.exists());
9544 }
9545
9546 #[test]
9547 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9548 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9549 let work = fixture.root.join("work");
9550 let seed = test_git(&work, &["rev-parse", "HEAD"]);
9551 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9552 let publish = |message: &str| {
9555 let oid = test_git(
9556 &work,
9557 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9558 );
9559 let oid = oid.trim().to_string();
9560 test_git(
9561 &work,
9562 &[
9563 "push",
9564 "--force",
9565 "origin",
9566 &format!("{oid}:refs/pull/923/head"),
9567 ],
9568 );
9569 oid
9570 };
9571 let reviewed = publish("reviewed head");
9572 repo.worktree_for_pr_head(923).unwrap();
9573 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9574
9575 let rewritten = publish("rewritten head");
9576 repo.worktree_for_pr_head(923).unwrap();
9577
9578 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9579 }
9580
9581 #[test]
9582 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9583 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9584 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9585 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9586 test_git(&path, &["add", "recovery.txt"]);
9587 test_git(&path, &["commit", "-m", "recovery commit"]);
9588 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9589 test_git(&path, &["reset", "--hard", "main"]);
9590
9591 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9592 test_git(
9593 &path,
9594 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9595 );
9596 }
9597
9598 #[test]
9599 fn a_review_ref_reflog_only_commit_prevents_deletion() {
9600 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9601 let local_ref = review_ref(921);
9602 let original = test_git(&path, &["rev-parse", &local_ref]);
9603 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9604 let recovery = test_git(
9605 &path,
9606 &[
9607 "commit-tree",
9608 tree.trim(),
9609 "-p",
9610 original.trim(),
9611 "-m",
9612 "review ref recovery",
9613 ],
9614 );
9615 test_git(
9616 &path,
9617 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9618 );
9619 test_git(
9620 &path,
9621 &["update-ref", &local_ref, original.trim(), recovery.trim()],
9622 );
9623
9624 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9625 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9626 test_git(
9627 &path,
9628 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9629 );
9630 }
9631
9632 #[test]
9633 fn an_unpublished_commit_message_draft_is_recoverable() {
9634 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9635 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9636 let git_dir = if raw.is_absolute() {
9637 raw
9638 } else {
9639 path.join(raw)
9640 };
9641 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9642
9643 assert!(repository_has_recoverable_work(&path, true).unwrap());
9644 assert_eq!(
9645 "unique recovery draft\n",
9646 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9647 );
9648 }
9649
9650 #[test]
9651 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9652 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9653 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9654
9655 let error = repo
9656 .release_review_worktree_checked(902, &checkpoint)
9657 .unwrap_err();
9658
9659 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9660 assert!(error.to_string().contains("kept for recovery"), "{error}");
9661 assert_eq!(
9662 "recover me\n",
9663 std::fs::read_to_string(path.join("README.md")).unwrap()
9664 );
9665 repo.release_review_worktree(902);
9666 }
9667
9668 #[test]
9669 fn a_review_commit_is_retained_after_a_checked_read() {
9670 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9671 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9672 test_git(&path, &["add", "review-note.txt"]);
9673 test_git(&path, &["commit", "-m", "local review recovery"]);
9674 let head = test_git(&path, &["rev-parse", "HEAD"]);
9675
9676 let error = repo
9677 .release_review_worktree_checked(903, &checkpoint)
9678 .unwrap_err();
9679
9680 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9681 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9682 assert_eq!(
9683 "recover me\n",
9684 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9685 );
9686 repo.release_review_worktree(903);
9687 }
9688
9689 #[test]
9693 fn an_ignored_review_file_is_retained_after_a_checked_read() {
9694 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9695 std::fs::create_dir_all(path.join("generated")).unwrap();
9696 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9697
9698 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9699 .expect("its own output is not a change");
9700 let error = repo
9701 .release_review_worktree_checked(904, &checkpoint)
9702 .unwrap_err();
9703
9704 assert!(error.to_string().contains("recoverable"), "{error}");
9705 assert_eq!(
9706 "recover me\n",
9707 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9708 );
9709 repo.release_review_worktree(904);
9710 }
9711
9712 #[test]
9713 fn a_preexisting_ignored_review_file_change_is_retained() {
9719 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9720 std::fs::create_dir_all(path.join("generated")).unwrap();
9721 let ignored = path.join("generated/recovery.txt");
9722 std::fs::write(&ignored, "before\n").unwrap();
9723 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9724 std::fs::write(&ignored, "after!\n").unwrap();
9725
9726 let error = repo
9727 .release_review_worktree_checked(905, &checkpoint)
9728 .unwrap_err();
9729
9730 assert!(error.to_string().contains("recoverable"), "{error}");
9731 assert!(path.exists());
9732 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9733 repo.release_review_worktree(905);
9734 }
9735
9736 #[test]
9737 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9738 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9739 std::fs::create_dir_all(path.join("generated")).unwrap();
9740 let ignored = path.join("generated/recovery.txt");
9741 std::fs::write(&ignored, "keep me\n").unwrap();
9742 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9743
9744 let error = repo
9745 .release_review_worktree_checked(906, &checkpoint)
9746 .unwrap_err();
9747
9748 assert!(error.to_string().contains("recoverable"), "{error}");
9749 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9750 }
9751
9752 #[test]
9753 fn overwriting_a_preexisting_untracked_file_is_detected() {
9754 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9755 let untracked = path.join("notes.txt");
9756 std::fs::write(&untracked, "before\n").unwrap();
9757 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9758 std::fs::write(&untracked, "after!\n").unwrap();
9759
9760 let error = repo
9761 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9762 .unwrap_err();
9763
9764 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9765 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9766 }
9767
9768 #[test]
9769 fn an_assume_unchanged_edit_is_detected() {
9770 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9771 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9772 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9773
9774 let error = repo
9775 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9776 .unwrap_err();
9777
9778 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9779 assert_eq!(
9780 "hidden\n",
9781 std::fs::read_to_string(path.join("README.md")).unwrap()
9782 );
9783 }
9784
9785 #[test]
9786 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9787 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9788 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9789 test_git(&path, &["add", "README.md"]);
9790 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9791
9792 let error = repo
9793 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9794 .unwrap_err();
9795
9796 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9797 assert_eq!(
9798 b"seed\r\n",
9799 std::fs::read(path.join("README.md")).unwrap().as_slice()
9800 );
9801 }
9802
9803 #[cfg(unix)]
9804 #[test]
9805 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9806 use std::os::unix::fs::PermissionsExt;
9807
9808 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9809 test_git(&path, &["config", "core.filemode", "false"]);
9810 let readme = path.join("README.md");
9811 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9812 permissions.set_mode(0o755);
9813 std::fs::set_permissions(&readme, permissions).unwrap();
9814 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9815
9816 let error = repo
9817 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9818 .unwrap_err();
9819
9820 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9821 assert_eq!(
9822 0o755,
9823 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9824 );
9825 }
9826
9827 #[test]
9828 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9829 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9830 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9831 test_git(&path, &["add", ".gitattributes"]);
9832 test_git(&path, &["commit", "-m", "select data filter"]);
9833 let baseline = repo.worktree_baseline(&path).unwrap();
9834 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9835
9836 assert!(repo
9837 .commit_pending_changes(&path, &baseline, "change data", "change data")
9838 .unwrap());
9839 let error = repo
9840 .refuse_unrepresented_tracked_changes(&path, &baseline)
9841 .unwrap_err();
9842
9843 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9844 assert_eq!(
9845 "secret: recover me\nnew\n",
9846 std::fs::read_to_string(path.join("data.txt")).unwrap()
9847 );
9848 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9849 }
9850
9851 #[test]
9852 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9853 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9854 std::fs::create_dir_all(path.join("target")).unwrap();
9855 let untracked = path.join("target/user.yaml");
9856 std::fs::write(&untracked, "user data\n").unwrap();
9857 let baseline = repo.worktree_baseline(&path).unwrap();
9858 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9859
9860 assert!(repo
9861 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9862 .unwrap());
9863
9864 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9865 assert_eq!(
9866 "?? target/user.yaml\n",
9867 test_git(&path, &["status", "--short", "--untracked-files=all"])
9868 );
9869 assert!(test_git(
9870 &path,
9871 &[
9872 "ls-tree",
9873 "-r",
9874 "--name-only",
9875 "HEAD",
9876 "--",
9877 "target/user.yaml"
9878 ]
9879 )
9880 .is_empty());
9881 }
9882
9883 #[test]
9884 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9885 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9886 std::fs::create_dir_all(path.join("target")).unwrap();
9887 let untracked = path.join("target/user.yaml");
9888 std::fs::write(&untracked, "before\n").unwrap();
9889 let baseline = repo.worktree_baseline(&path).unwrap();
9890 let before = test_git(&path, &["rev-parse", "HEAD"]);
9891 std::fs::write(&untracked, "after\n").unwrap();
9892 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9893
9894 let error = repo
9895 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9896 .unwrap_err();
9897
9898 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9899 assert!(error.to_string().contains("target/user.yaml"), "{error}");
9900 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9901 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9902 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9903 }
9904
9905 #[test]
9906 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9907 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9908 let baseline = repo.worktree_baseline(&path).unwrap();
9909 std::fs::create_dir_all(path.join("target")).unwrap();
9910 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9911
9912 assert!(repo
9913 .commit_pending_changes(&path, &baseline, "add file", "add file")
9914 .unwrap());
9915
9916 assert_eq!(
9917 "new file\n",
9918 test_git(&path, &["show", "HEAD:target/new.txt"])
9919 );
9920 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9921 }
9922
9923 #[test]
9924 fn a_managed_commit_accepts_a_custom_output_rebuild() {
9925 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9926 exclude_paths(&repo, &["manager/public/"]);
9927 let output = path.join("manager/public/assets");
9928 std::fs::create_dir_all(&output).unwrap();
9929 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9930 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9931 let baseline = repo.worktree_baseline(&path).unwrap();
9932 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9933 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9934 std::fs::remove_file(output.join("old.js")).unwrap();
9935 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9936 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9937
9938 assert!(repo
9939 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9940 .unwrap());
9941 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9942 repo.refuse_changed_existing_untracked(&path, &baseline)
9943 .unwrap();
9944 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9945 .unwrap();
9946
9947 assert_eq!(
9948 "tracked change\n",
9949 test_git(&path, &["show", "HEAD:README.md"])
9950 );
9951 assert_eq!(
9952 "new source\n",
9953 test_git(&path, &["show", "HEAD:new-source.txt"])
9954 );
9955 assert!(test_git(
9956 &path,
9957 &[
9958 "ls-tree",
9959 "-r",
9960 "--name-only",
9961 "HEAD",
9962 "--",
9963 "manager/public"
9964 ]
9965 )
9966 .is_empty());
9967 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9968 assert!(!output.join("old.js").exists());
9969 assert_eq!(
9970 "new styles\n",
9971 std::fs::read_to_string(output.join("index.css")).unwrap()
9972 );
9973 assert!(repository_has_recoverable_work(&path, true).unwrap());
9974 repo.release_review_worktree(912);
9975 assert!(output.join("index-DUym-Rfj.js").exists());
9976 }
9977
9978 #[test]
9979 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9980 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9981 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9982 let baseline = repo.worktree_baseline(&path).unwrap();
9983 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9984 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9985
9986 let error = repo
9987 .commit_pending_changes(&path, &baseline, "change", "change")
9988 .unwrap_err();
9989
9990 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9991 assert!(error.to_string().contains("notes.txt"), "{error}");
9992 }
9993
9994 #[test]
9995 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9996 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9997 let baseline = repo.worktree_baseline(&path).unwrap();
9998 let nested = path.join("generated/repro");
9999 std::fs::create_dir_all(&nested).unwrap();
10000 test_git(&nested, &["init"]);
10001 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10002
10003 assert!(repo
10004 .commit_pending_changes(&path, &baseline, "change", "change")
10005 .unwrap());
10006 assert!(!repo.has_uncommitted_changes(&path).unwrap());
10007 assert!(repository_has_recoverable_work(&path, true).unwrap());
10008 repo.release_review_worktree(927);
10009 assert!(nested.join(".git").exists());
10010 }
10011
10012 #[test]
10013 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
10014 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
10015 test_git(&path, &["config", "core.autocrlf", "true"]);
10016 std::fs::write(path.join("README.md"), "seed\n").unwrap();
10017 assert_eq!(
10018 test_git(&path, &["hash-object", "README.md"]).trim(),
10019 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10020 );
10021
10022 assert!(repository_has_recoverable_work(&path, true).unwrap());
10023 assert_eq!(
10024 "seed\n",
10025 std::fs::read_to_string(path.join("README.md")).unwrap()
10026 );
10027 }
10028
10029 #[test]
10030 fn autocrlf_input_overrides_a_crlf_core_eol() {
10031 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
10032 test_git(&path, &["config", "core.autocrlf", "input"]);
10033 test_git(&path, &["config", "core.eol", "crlf"]);
10034 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
10035 assert_eq!(
10036 test_git(&path, &["hash-object", "README.md"]).trim(),
10037 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10038 );
10039
10040 assert!(repository_has_recoverable_work(&path, true).unwrap());
10041 assert_eq!(
10042 b"seed\r\n",
10043 std::fs::read(path.join("README.md")).unwrap().as_slice()
10044 );
10045 }
10046
10047 #[cfg(unix)]
10048 #[test]
10049 fn a_non_executable_permission_change_is_recoverable() {
10050 use std::os::unix::fs::PermissionsExt;
10051
10052 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
10053 let readme = path.join("README.md");
10054 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
10055 permissions.set_mode(0o600);
10056 std::fs::set_permissions(&readme, permissions).unwrap();
10057 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10058
10059 let error = repo
10060 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10061 .unwrap_err();
10062
10063 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10064 assert!(repository_has_recoverable_work(&path, true).unwrap());
10065 assert_eq!(
10066 0o600,
10067 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
10068 );
10069 }
10070
10071 #[cfg(unix)]
10072 #[test]
10073 fn a_managed_commit_skips_signing_and_hooks() {
10074 use std::os::unix::fs::PermissionsExt;
10075
10076 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
10077 let common = common_git_dir(&path).unwrap();
10078 let hook = common.join("hooks/pre-commit");
10079 let marker = fixture.root.join("hook-ran");
10080 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
10081 std::fs::write(
10082 &hook,
10083 format!(
10084 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
10085 sh_quote(marker.to_str().unwrap())
10086 ),
10087 )
10088 .unwrap();
10089 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
10090 permissions.set_mode(0o755);
10091 std::fs::set_permissions(&hook, permissions).unwrap();
10092 test_git(&path, &["config", "commit.gpgsign", "true"]);
10093 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
10094 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
10095 test_git(&path, &["add", "managed.txt"]);
10096
10097 repo.commit_staged_changes(&path, "record managed change")
10098 .unwrap();
10099
10100 assert!(!marker.exists());
10101 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
10102 }
10103
10104 #[test]
10105 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
10106 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
10107 std::fs::write(
10108 path.join(".gitattributes"),
10109 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
10110 )
10111 .unwrap();
10112 test_git(&path, &["add", ".gitattributes"]);
10113 test_git(&path, &["commit", "-m", "select automatic text"]);
10114 test_git(&path, &["config", "core.autocrlf", "true"]);
10115
10116 assert!(repository_has_recoverable_work(&path, true).unwrap());
10117 }
10118
10119 #[test]
10120 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
10121 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
10122 std::fs::write(
10123 path.join(".gitattributes"),
10124 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
10125 )
10126 .unwrap();
10127 test_git(&path, &["add", ".gitattributes"]);
10128 test_git(&path, &["commit", "-m", "select ident expansion"]);
10129 std::fs::write(path.join("README.md"), "seed\n").unwrap();
10130
10131 assert!(repository_has_recoverable_work(&path, true).unwrap());
10132 }
10133
10134 #[test]
10140 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
10141 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
10142 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
10143 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10144 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
10145 std::fs::write(
10146 path.join("bindings/uniffi-bindgen/Cargo.lock"),
10147 "# generated\n",
10148 )
10149 .unwrap();
10150
10151 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10152 .expect("a path the project ignores is the call's own output");
10153 }
10154
10155 #[test]
10156 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
10157 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
10158 exclude_paths(&repo, &["target/"]);
10159 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10160 std::fs::create_dir_all(path.join("target/package")).unwrap();
10161 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
10162
10163 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10164 .expect("an ignored attribute file governs only ignored paths");
10165 assert!(
10166 !repo.has_uncommitted_changes(&path).unwrap(),
10167 "ignored dependency attributes must not stop the next review"
10168 );
10169 }
10170
10171 #[test]
10172 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
10173 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
10174 std::fs::create_dir_all(path.join("source")).unwrap();
10175 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
10176
10177 assert!(repo.has_uncommitted_changes(&path).unwrap());
10178 }
10179
10180 #[test]
10181 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
10182 for source in ["repository", "nested", "local", "global"] {
10183 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
10184 std::fs::create_dir_all(path.join("manager")).unwrap();
10185 match source {
10186 "repository" => {
10187 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
10188 .unwrap();
10189 test_git(&path, &["add", ".gitignore"]);
10190 test_git(&path, &["commit", "-m", "ignore artifacts"]);
10191 }
10192 "nested" => {
10193 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
10194 test_git(&path, &["add", "manager/.gitignore"]);
10195 test_git(&path, &["commit", "-m", "ignore artifacts"]);
10196 }
10197 "local" => exclude_paths(&repo, &["manager/scratch/"]),
10198 "global" => {
10199 let excludes = repo.root().parent().unwrap().join("global-ignore");
10200 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
10201 test_git(
10202 &path,
10203 &["config", "core.excludesFile", excludes.to_str().unwrap()],
10204 );
10205 }
10206 _ => unreachable!(),
10207 }
10208 let output = path.join("manager/scratch");
10209 std::fs::create_dir_all(&output).unwrap();
10210 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
10211 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
10212 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10213 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
10214 std::fs::remove_file(output.join(".DS_Store")).unwrap();
10215 for file in [
10216 ".gitattributes",
10217 "Cargo.lock",
10218 "font.woff2",
10219 "bundle.js",
10220 "cache.db",
10221 ] {
10222 std::fs::write(output.join(file), "generated\n").unwrap();
10223 }
10224 #[cfg(unix)]
10225 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
10226 let nested = output.join("dependency");
10227 std::fs::create_dir_all(&nested).unwrap();
10228 test_git(&nested, &["init"]);
10229 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10230 .unwrap_or_else(|error| panic!("{source}: {error}"));
10231 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10232
10233 let baseline = repo.worktree_baseline(&path).unwrap();
10234 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
10235 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
10236 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
10237 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
10238 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
10239 assert!(repo
10240 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
10241 .unwrap_or_else(|error| panic!("{source}: {error}")));
10242 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10243 assert!(test_git(
10244 &path,
10245 &[
10246 "ls-tree",
10247 "-r",
10248 "--name-only",
10249 "HEAD",
10250 "--",
10251 "manager/scratch"
10252 ]
10253 )
10254 .is_empty());
10255 assert!(
10256 repository_has_recoverable_work(&path, true).unwrap(),
10257 "{source}"
10258 );
10259 repo.release_review_worktree(945);
10260 assert_eq!(
10261 "LOCAL=updated\n",
10262 std::fs::read_to_string(output.join(".env.local")).unwrap()
10263 );
10264 assert!(nested.join(".git").exists(), "{source}");
10265 }
10266 }
10267
10268 #[test]
10272 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
10273 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
10274 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10275 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
10276
10277 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10278 .expect("spar's own marker is not something a call did");
10279 }
10280
10281 #[test]
10282 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
10283 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
10284
10285 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
10286 .expect("a path a build removed is not an unreadable worktree");
10287
10288 assert_eq!(vanished_untracked_file(), gone);
10289 }
10290
10291 #[test]
10294 fn a_vanished_path_is_not_recorded_as_absent() {
10295 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
10296 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10297 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10298 std::fs::remove_file(path.join("notes.txt")).unwrap();
10299
10300 let error = repo
10301 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10302 .unwrap_err();
10303
10304 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10305 }
10306
10307 fn exclude_paths(repo: &Repo, lines: &[&str]) {
10308 use std::io::Write;
10309 let path = repo.root().join(".git").join("info").join("exclude");
10310 let mut file = std::fs::OpenOptions::new()
10311 .create(true)
10312 .append(true)
10313 .open(&path)
10314 .unwrap();
10315 for line in lines {
10316 writeln!(file, "{line}").unwrap();
10317 }
10318 }
10319
10320 #[test]
10321 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
10322 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
10323 exclude_paths(&repo, &["dist/"]);
10324 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10325 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10326 let baseline = repo.worktree_baseline(&path).unwrap();
10327 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10328 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10329 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10330 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10331
10332 assert!(repo
10333 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10334 .unwrap());
10335 assert_eq!(
10336 "tracked change\n",
10337 std::fs::read_to_string(path.join("README.md")).unwrap()
10338 );
10339 }
10340
10341 #[test]
10342 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10343 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10344 exclude_paths(&repo, &["dist/"]);
10345 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10346 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10347 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10348 let baseline = repo.worktree_baseline(&path).unwrap();
10349 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10350 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10351 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10352 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10353
10354 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10355 repo.refuse_changed_existing_untracked(&path, &baseline)
10356 .unwrap();
10357 }
10358
10359 #[test]
10360 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10361 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10362 exclude_paths(&repo, &["dist/"]);
10363 std::fs::create_dir_all(path.join("dist")).unwrap();
10364 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10365 let baseline = repo.worktree_baseline(&path).unwrap();
10366 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10367 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10368
10369 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10370 repo.refuse_changed_existing_untracked(&path, &baseline)
10371 .unwrap();
10372 }
10373
10374 #[test]
10383 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10384 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10385 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10386 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10387 test_git(
10388 repo.root(),
10389 &[
10390 "worktree",
10391 "add",
10392 "-b",
10393 "issue-950",
10394 held.to_str().unwrap(),
10395 "main",
10396 ],
10397 );
10398
10399 let found = repo
10400 .worktree_holding("issue-950")
10401 .expect("the branch is checked out somewhere");
10402
10403 assert!(found.ends_with("issue-950"), "{}", found.display());
10404 assert_eq!(None, repo.worktree_holding("issue-951"));
10405 }
10406
10407 #[test]
10408 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10409 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10410 exclude_paths(&repo, &["manager/public/assets/"]);
10411 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10412 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10413 std::fs::write(&bundle, "first build\n").unwrap();
10414 let baseline = repo.worktree_baseline(&path).unwrap();
10415 std::fs::write(&bundle, "second build\n").unwrap();
10416 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10417
10418 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10419 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10420 }
10421
10422 #[test]
10425 fn a_file_a_reviewer_created_is_moved_aside_and_the_checkout_is_whole_again() {
10426 let (_fixture, repo, path, checkpoint) = review_fixture("scratch-aside", 945);
10427 std::fs::create_dir_all(path.join("tests")).unwrap();
10428 std::fs::write(path.join("tests/zz-repro.test.ts"), "repro\n").unwrap();
10429
10430 repo.set_aside_review_scratch(&path, &checkpoint, 945);
10431
10432 assert!(!path.join("tests/zz-repro.test.ts").exists());
10433 let shelves: Vec<PathBuf> = std::fs::read_dir(repo.root().join(".spar/scratch"))
10434 .unwrap()
10435 .flatten()
10436 .map(|entry| entry.path())
10437 .collect();
10438 assert_eq!(1, shelves.len(), "{shelves:?}");
10439 assert_eq!(
10440 "repro\n",
10441 std::fs::read_to_string(shelves[0].join("tests/zz-repro.test.ts")).unwrap()
10442 );
10443 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10444 .unwrap();
10445 }
10446
10447 #[test]
10451 fn only_a_file_that_was_not_there_is_moved_aside() {
10452 let (_fixture, repo, path, _checkpoint) = review_fixture("scratch-only-new", 946);
10453 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10454 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10455 std::fs::write(path.join("README.md"), "rewritten\n").unwrap();
10456 std::fs::write(path.join("notes.txt"), "after\n").unwrap();
10457
10458 repo.set_aside_review_scratch(&path, &checkpoint, 946);
10459
10460 assert_eq!(
10461 "rewritten\n",
10462 std::fs::read_to_string(path.join("README.md")).unwrap()
10463 );
10464 assert_eq!(
10465 "after\n",
10466 std::fs::read_to_string(path.join("notes.txt")).unwrap()
10467 );
10468 assert!(!repo.root().join(".spar/scratch").exists());
10469 let error = repo
10470 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10471 .unwrap_err();
10472 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10473 assert!(error.to_string().contains("README.md"), "{error}");
10474 assert!(error.to_string().contains("notes.txt"), "{error}");
10475 }
10476
10477 #[test]
10478 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10479 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10480 exclude_paths(&repo, &["dist/"]);
10481 let baseline = repo.worktree_baseline(&path).unwrap();
10482 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10483
10484 let error = repo
10485 .refuse_read_only_leavings(&path, &baseline)
10486 .unwrap_err();
10487
10488 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10489 assert!(error.to_string().contains("scratch.md"), "{error}");
10490 }
10491
10492 #[test]
10493 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10494 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10495 exclude_paths(&repo, &["dist/", ".env.local"]);
10496 std::fs::create_dir_all(path.join("dist")).unwrap();
10497 let baseline = repo.worktree_baseline(&path).unwrap();
10498 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10499 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10500
10501 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10502 assert!(repository_has_recoverable_work(&path, true).unwrap());
10503 }
10504
10505 #[test]
10506 fn a_read_only_inspection_may_rebuild_generated_output() {
10507 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10508 exclude_paths(&repo, &["dist/"]);
10509 std::fs::create_dir_all(path.join("dist")).unwrap();
10510 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10511 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10512 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10513 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10514
10515 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10516 .unwrap();
10517 }
10518
10519 #[test]
10520 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10521 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10522 exclude_paths(&repo, &["dist/", ".env.local"]);
10523 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10524 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10525 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10526
10527 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10528 .unwrap();
10529 }
10530
10531 #[test]
10532 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10533 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10534 exclude_paths(&repo, &["dist/", ".env.local"]);
10535 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10536 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10537 std::fs::remove_file(path.join(".env.local")).unwrap();
10538
10539 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10540 .unwrap();
10541 }
10542
10543 #[test]
10544 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10545 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10546 exclude_paths(&repo, &["dist/"]);
10547 std::fs::write(path.join("notes.md"), "before\n").unwrap();
10548 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10549 std::fs::write(path.join("notes.md"), "after\n").unwrap();
10550
10551 let error = repo
10552 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10553 .unwrap_err();
10554
10555 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10556 assert!(error.to_string().contains("notes.md"), "{error}");
10557 }
10558
10559 #[test]
10560 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10561 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10562 exclude_paths(&repo, &["dist/", "local.env"]);
10563 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10564 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10565 std::fs::write(
10568 repo.root().join(".git").join("info").join("exclude"),
10569 "dist/\n",
10570 )
10571 .unwrap();
10572
10573 let error = repo
10574 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10575 .unwrap_err();
10576
10577 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10578 assert!(error.to_string().contains("local.env"), "{error}");
10579 }
10580
10581 #[test]
10582 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10583 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10584 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10585 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10586
10587 let error = repo
10588 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10589 .unwrap_err();
10590
10591 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10592 assert!(error.to_string().contains("README.md"), "{error}");
10593 }
10594
10595 #[test]
10596 fn build_output_alone_does_not_keep_a_worktree() {
10597 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10598 exclude_paths(&repo, &["target/", "dist/"]);
10599 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10600 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10601 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10602 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10603
10604 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10605 repo.release_review_worktree(933);
10606
10607 assert!(!path.exists());
10608 }
10609
10610 #[test]
10611 fn finder_metadata_alone_does_not_keep_a_worktree() {
10612 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10613 exclude_paths(&repo, &[".DS_Store"]);
10614 std::fs::create_dir_all(path.join("src")).unwrap();
10615 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10616 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10617
10618 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10619 repo.release_review_worktree(937);
10620
10621 assert!(!path.exists());
10622 }
10623
10624 #[test]
10625 fn a_fetch_record_alone_does_not_keep_a_worktree() {
10626 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10627 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10628 let git_dir = if raw.is_absolute() {
10629 raw
10630 } else {
10631 path.join(raw)
10632 };
10633 let head = test_git(&path, &["rev-parse", "HEAD"]);
10634 std::fs::write(
10635 git_dir.join("FETCH_HEAD"),
10636 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10637 )
10638 .unwrap();
10639
10640 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10641 repo.release_review_worktree(938);
10642
10643 assert!(!path.exists());
10644 }
10645
10646 #[test]
10647 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10648 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10649 exclude_paths(&repo, &["target/", ".env.local"]);
10650 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10651 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10652 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10653
10654 assert!(repository_has_recoverable_work(&path, true).unwrap());
10655 repo.release_review_worktree(934);
10656
10657 assert_eq!(
10658 "TOKEN=keep me\n",
10659 std::fs::read_to_string(path.join(".env.local")).unwrap()
10660 );
10661 }
10662
10663 #[test]
10664 fn a_repository_nested_in_build_output_keeps_a_worktree() {
10665 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10666 exclude_paths(&repo, &["node_modules/"]);
10667 let nested = path.join("node_modules/local-dep");
10668 std::fs::create_dir_all(&nested).unwrap();
10669 test_git(&nested, &["init"]);
10670 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10671
10672 assert!(repository_has_recoverable_work(&path, true).unwrap());
10673 repo.release_review_worktree(935);
10674
10675 assert!(nested.join(".git").exists());
10676 }
10677
10678 #[test]
10679 fn an_ordinary_untracked_file_keeps_a_worktree() {
10680 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10681 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10682
10683 assert!(repository_has_recoverable_work(&path, true).unwrap());
10684 repo.release_review_worktree(936);
10685
10686 assert_eq!(
10687 "somebody's notes\n",
10688 std::fs::read_to_string(path.join("notes.md")).unwrap()
10689 );
10690 }
10691
10692 #[test]
10693 fn a_legacy_crlf_checkout_is_retained_conservatively() {
10694 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10695 std::fs::write(
10696 path.join(".gitattributes"),
10697 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10698 )
10699 .unwrap();
10700 test_git(&path, &["add", ".gitattributes"]);
10701 test_git(&path, &["commit", "-m", "select legacy line endings"]);
10702
10703 assert!(repository_has_recoverable_work(&path, true).unwrap());
10704 }
10705
10706 #[test]
10707 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10708 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10709 let nested = path.join("tracked");
10710 std::fs::create_dir_all(&nested).unwrap();
10711 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10712 test_git(&path, &["add", "tracked/seed.txt"]);
10713 test_git(&path, &["commit", "-m", "add tracked directory"]);
10714 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10715 test_git(&nested, &["init"]);
10716
10717 let error = repo
10718 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10719 .unwrap_err();
10720
10721 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10722 assert!(error.to_string().contains("Git entry"), "{error}");
10723 assert!(nested.join(".git").exists());
10724 }
10725
10726 #[test]
10731 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10732 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10733 exclude_paths(&repo, &["target/"]);
10734 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10735 let repro = path.join("target/pr918-repro");
10736 std::fs::create_dir_all(&repro).unwrap();
10737 test_git(&repro, &["init"]);
10738 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10739
10740 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10741 .unwrap();
10742
10743 assert!(repository_has_recoverable_work(&path, true).unwrap());
10744 assert!(repro.join(".git").exists());
10745 }
10746
10747 #[test]
10751 fn a_repository_inside_a_nested_checkout_is_left_alone() {
10752 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10753 exclude_paths(&repo, &["target/"]);
10754 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10755 let repro = path.join("target/repro");
10756 let vendored = repro.join("vendor/dep");
10757 std::fs::create_dir_all(&vendored).unwrap();
10758 test_git(&repro, &["init"]);
10759 test_git(&vendored, &["init"]);
10760
10761 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10762 .unwrap();
10763
10764 assert!(vendored.join(".git").exists());
10765 }
10766
10767 #[test]
10768 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10769 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10770
10771 let state = ignored_untracked_state(repo.root()).unwrap();
10772
10773 let relative = path.strip_prefix(repo.root()).unwrap();
10774 assert!(
10775 state.files.contains_key(relative),
10776 "{:?}",
10777 state.files.keys().collect::<Vec<_>>()
10778 );
10779 assert!(state.is_ignored(relative));
10780 }
10781
10782 #[test]
10783 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10784 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10785 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10786 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10787 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10788
10789 repo.refuse_new_ignored_files(repo.root(), &baseline)
10790 .unwrap();
10791 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10792 .unwrap();
10793 }
10794
10795 #[test]
10796 fn deleting_a_resident_worktree_during_a_call_is_refused() {
10797 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10798 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10799 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10800 std::fs::remove_dir_all(&path).unwrap();
10801
10802 let read_error = repo
10803 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10804 .unwrap_err();
10805 assert!(
10806 read_error.to_string().contains("review-932"),
10807 "{read_error}"
10808 );
10809 let error = repo
10810 .refuse_new_ignored_files(repo.root(), &baseline)
10811 .unwrap_err();
10812
10813 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10814 assert!(error.to_string().contains("review-932"), "{error}");
10815 }
10816
10817 #[test]
10818 fn a_nested_repository_record_is_read_as_a_plain_path() {
10819 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10820 assert_eq!(Path::new("vendor/checkout"), path);
10821 assert!(nested);
10822
10823 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10824 assert_eq!(Path::new("vendor/notes.txt"), path);
10825 assert!(!nested);
10826
10827 assert!(untracked_record(b"/", "untracked").is_err());
10828 }
10829
10830 #[cfg(unix)]
10831 #[test]
10832 fn a_non_utf8_git_path_is_preserved_without_loss() {
10833 use std::os::unix::ffi::OsStrExt;
10834
10835 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10836
10837 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10838 }
10839
10840 #[test]
10841 fn guarded_merge_pins_the_reviewed_head() {
10842 let args = merge_pr_args("36", Some("abc123"), true);
10843 assert_eq!(
10844 vec![
10845 "pr",
10846 "merge",
10847 "36",
10848 "--squash",
10849 "--delete-branch",
10850 "--match-head-commit",
10851 "abc123"
10852 ],
10853 args
10854 );
10855 }
10856
10857 #[test]
10858 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10859 let pr = PrRef {
10860 number: 7,
10861 url: "https://example.test/pull/7".into(),
10862 title: "part one".into(),
10863 };
10864 let result = reconcile_pr_creation(
10865 "split-34-1",
10866 Err(crate::error::SparError::new("connection lost")),
10867 Ok(Some(pr)),
10868 )
10869 .unwrap();
10870 assert_eq!(7, result.number);
10871 }
10872
10873 #[test]
10874 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10875 let error = reconcile_pr_creation(
10876 "split-34-1",
10877 Err(crate::error::SparError::new("permission denied")),
10878 Ok(None),
10879 )
10880 .unwrap_err();
10881 assert!(error.to_string().contains("permission denied"), "{error}");
10882 }
10883
10884 #[test]
10885 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10886 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10887 assert!(pr_for_base(text, "split-34-2", "split-34-1")
10888 .unwrap()
10889 .is_none());
10890 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10891 assert_eq!(7, found.number);
10892 }
10893
10894 #[test]
10895 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10896 let result = reconcile_comment_post(
10897 34,
10898 "the summary",
10899 crate::error::SparError::new("connection lost"),
10900 Ok(vec![serde_json::json!({"body": "the summary"})]),
10901 );
10902 assert!(result.is_ok(), "{result:?}");
10903 }
10904
10905 #[test]
10906 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10907 let error = reconcile_comment_post(
10908 34,
10909 "the summary",
10910 crate::error::SparError::new("connection lost"),
10911 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10912 )
10913 .unwrap_err();
10914 assert_eq!("connection lost", error.to_string());
10915 }
10916
10917 #[test]
10918 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10919 let error = reconcile_comment_post(
10920 34,
10921 "the summary",
10922 crate::error::SparError::new("connection lost"),
10923 Err(crate::error::SparError::new("comments unavailable")),
10924 )
10925 .unwrap_err();
10926 assert!(
10927 error.to_string().contains("could not be verified"),
10928 "{error}"
10929 );
10930 assert!(
10931 error.to_string().contains("comments unavailable"),
10932 "{error}"
10933 );
10934 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10935 assert!(!error.worth_retrying());
10936 }
10937
10938 #[test]
10939 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10940 let result = reconcile_issue_edit(
10941 34,
10942 "wanted body",
10943 crate::error::SparError::new("connection lost"),
10944 Ok("wanted body".to_string()),
10945 );
10946 assert!(result.is_ok(), "{result:?}");
10947 }
10948
10949 #[test]
10950 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10951 let error = reconcile_issue_edit(
10952 34,
10953 "wanted body",
10954 crate::error::SparError::new("connection lost"),
10955 Err(crate::error::SparError::new("issue unavailable")),
10956 )
10957 .unwrap_err();
10958 assert!(
10959 error.to_string().contains("could not be verified"),
10960 "{error}"
10961 );
10962 assert!(error.to_string().contains("issue unavailable"), "{error}");
10963 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10964 assert!(!error.worth_retrying());
10965 }
10966
10967 #[test]
10968 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10969 let found = ExistingIssue {
10970 number: 101,
10971 url: "https://example.test/issues/101".into(),
10972 title: "child".into(),
10973 body: "body".into(),
10974 open: true,
10975 };
10976 let url = reconcile_issue_creation(
10977 "child",
10978 Err(crate::error::SparError::new("connection lost")),
10979 Ok(Some(found)),
10980 )
10981 .unwrap();
10982 assert_eq!("https://example.test/issues/101", url);
10983 }
10984
10985 #[test]
10986 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10987 let error = reconcile_issue_creation(
10988 "child",
10989 Err(crate::error::SparError::new("permission denied")),
10990 Ok(None),
10991 )
10992 .unwrap_err();
10993 assert!(error.to_string().contains("permission denied"), "{error}");
10994 }
10995
10996 #[test]
10997 fn an_unverifiable_issue_creation_is_marked_uncertain() {
10998 let error = reconcile_issue_creation(
10999 "child",
11000 Err(crate::error::SparError::new("connection lost")),
11001 Err(crate::error::SparError::new("issues unavailable")),
11002 )
11003 .unwrap_err();
11004 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11005 assert!(!error.worth_retrying());
11006 }
11007
11008 #[test]
11009 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
11010 let result = reconcile_failed_split_push(
11011 "split-34-1",
11012 crate::error::SparError::new("connection lost"),
11013 Ok("abc123\n".into()),
11014 Ok("abc123\trefs/heads/split-34-1\n".into()),
11015 );
11016 assert!(result.is_ok(), "{result:?}");
11017 }
11018
11019 #[test]
11020 fn a_split_push_collision_is_definite_and_never_overwrites() {
11021 let error = reconcile_failed_split_push(
11022 "split-34-1",
11023 crate::error::SparError::new("lease rejected"),
11024 Ok("abc123\n".into()),
11025 Ok("def456\trefs/heads/split-34-1\n".into()),
11026 )
11027 .unwrap_err();
11028 assert!(!error.retain_worktree());
11029 assert!(
11030 error.to_string().contains("Nothing was overwritten"),
11031 "{error}"
11032 );
11033 }
11034
11035 #[test]
11036 fn an_unreadable_split_push_result_keeps_the_worktree() {
11037 let error = reconcile_failed_split_push(
11038 "split-34-1",
11039 crate::error::SparError::new("connection lost"),
11040 Ok("abc123\n".into()),
11041 Err(crate::error::SparError::new("origin unavailable")),
11042 )
11043 .unwrap_err();
11044 assert!(error.retain_worktree());
11045 assert!(error.to_string().contains("could not confirm"), "{error}");
11046 }
11047
11048 #[test]
11052 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
11053 let repo = repo_for_titles();
11054 for raw in [
11055 "Retry loop spins \u{2014} Retry-After parses to zero",
11056 "plain title",
11057 " spread over\nlines ",
11058 "\u{1F916} Generated with something",
11059 &format!("a \u{2014} {}", "very long title ".repeat(20)),
11060 &"x".repeat(300),
11061 &format!("{} \u{2014} end", "y".repeat(88)),
11062 &{
11067 let tail = "a\u{2014}b c\u{2014}d";
11068 let pad = Style::default().max_title_chars - tail.chars().count();
11069 format!("{}{tail}", "w".repeat(pad))
11070 },
11071 ] {
11072 let once = repo.clean_title(raw).unwrap();
11073 let twice = repo.clean_title(&once).unwrap();
11074 assert_eq!(once, twice, "not idempotent for {raw:?}");
11075 assert!(
11076 once.chars().count() <= repo.style.max_title_chars,
11077 "over budget: {once:?}"
11078 );
11079 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
11080 }
11081 }
11082
11083 #[test]
11084 fn a_title_with_an_em_dash_survives_as_readable_text() {
11085 let repo = repo_for_titles();
11086 assert_eq!(
11087 "Retry loop spins, Retry-After parses to zero",
11088 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
11089 .unwrap()
11090 );
11091 }
11092
11093 #[test]
11094 fn sh_quote_survives_a_quote() {
11095 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
11096 }
11097
11098 #[test]
11099 fn sh_quote_wraps_a_space() {
11100 assert_eq!(
11101 "'/Applications/My App/spar'",
11102 sh_quote("/Applications/My App/spar")
11103 );
11104 }
11105
11106 #[test]
11107 fn finished_states_are_recognised_case_insensitively() {
11108 assert!(is_finished("MERGED"));
11109 assert!(is_finished("closed"));
11110 assert!(!is_finished("OPEN"));
11111 assert!(!is_finished(""));
11112 }
11113
11114 fn state() -> PersistedState {
11115 PersistedState {
11116 version: 1,
11117 checkpoint: 0,
11118 round: 4,
11119 next_actor: "codex".into(),
11120 status: Status::Pending,
11121 pr_head: "abc123".into(),
11122 ledger: Ledger::new(),
11123 filed: vec![],
11124 open_findings: vec![Finding {
11125 severity: Severity::Blocking,
11126 title: "Unchecked error".into(),
11127 detail: "the failure is discarded".into(),
11128 file: "src/a.rs:12".into(),
11129 ..Finding::default()
11130 }],
11131 disputes: vec![Dispute {
11132 title: "Retry limit".into(),
11133 file: "src/net.rs".into(),
11134 reasoning: "the caller already bounds it".into(),
11135 }],
11136 noted: vec![Finding {
11137 severity: Severity::NonBlocking,
11138 title: "Timeout is fixed".into(),
11139 file: "src/config.rs".into(),
11140 ..Finding::default()
11141 }],
11142 }
11143 }
11144
11145 #[test]
11146 fn a_state_comment_round_trips() {
11147 let body = format!(
11148 "{STATE_MARKER}\n{}\n-->",
11149 serde_json::to_string(&state()).unwrap()
11150 );
11151 let back = parse_state_comment(&body).unwrap();
11152 assert_eq!(4, back.round);
11153 assert_eq!("codex", back.next_actor);
11154 assert_eq!("abc123", back.pr_head);
11155 assert_eq!("Unchecked error", back.open_findings[0].title);
11156 assert_eq!("src/net.rs", back.disputes[0].file);
11157 assert_eq!("Timeout is fixed", back.noted[0].title);
11158 }
11159
11160 #[test]
11161 fn old_state_without_new_lists_still_parses() {
11162 let body = format!(
11163 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
11164 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11165 );
11166 let back = parse_state_comment(&body).expect("old state");
11167 assert!(back.open_findings.is_empty());
11168 assert!(back.disputes.is_empty());
11169 assert!(back.noted.is_empty());
11170 assert!(back.pr_head.is_empty());
11171 assert_eq!(0, back.checkpoint);
11172 }
11173
11174 #[test]
11175 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
11176 let mut local = state();
11177 local.pr_head = "old".into();
11178 local.round = 9;
11179 let mut remote = state();
11180 remote.pr_head = "current".into();
11181 remote.round = 4;
11182
11183 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11184 assert_eq!("current", chosen.pr_head);
11185 assert_eq!(4, chosen.round);
11186 }
11187
11188 #[test]
11189 fn checkpoint_order_breaks_same_round_ties() {
11190 let mut local = state();
11191 local.pr_head = "current".into();
11192 local.round = 4;
11193 local.checkpoint = 8;
11194 let mut remote = local.clone();
11195 remote.checkpoint = 7;
11196 remote.open_findings.clear();
11197
11198 let chosen = choose_state_for_head(vec![local], "current").unwrap();
11199 assert_eq!(8, chosen.checkpoint);
11200
11201 let mut local = state();
11202 local.pr_head = "current".into();
11203 local.round = 4;
11204 local.checkpoint = 8;
11205 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
11206 assert_eq!(8, chosen.checkpoint);
11207 }
11208
11209 #[test]
11210 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
11211 let mut local = state();
11212 local.pr_head = "current".into();
11213 local.round = 4;
11214 local.open_findings.push(Finding {
11215 title: "local checkpoint".into(),
11216 ..Finding::default()
11217 });
11218 let mut remote = state();
11219 remote.pr_head = "current".into();
11220 remote.round = 4;
11221
11222 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11223 assert_eq!(
11224 "local checkpoint",
11225 chosen.open_findings.last().unwrap().title
11226 );
11227 }
11228
11229 #[test]
11231 fn the_state_block_is_an_html_comment() {
11232 let body = format!(
11233 "{STATE_MARKER}\n{}\n-->",
11234 serde_json::to_string(&state()).unwrap()
11235 );
11236 assert!(body.starts_with("<!--"));
11237 assert!(body.trim_end().ends_with("-->"));
11238 assert!(!body[..body.find('{').unwrap()].contains("-->"));
11239 }
11240
11241 #[test]
11242 fn an_unrelated_json_block_is_not_state() {
11243 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
11244 }
11245
11246 #[test]
11253 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
11254 let block = format!(
11255 "{STATE_MARKER}\n{}\n-->",
11256 serde_json::to_string(&state()).unwrap()
11257 );
11258 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
11259 assert!(
11260 is_state_comment(&block, "Spar-Bot", "spar-bot"),
11261 "a login differs only in case"
11262 );
11263
11264 assert!(
11265 !is_state_comment(
11266 "why is there a `<!-- spar:state` block on this PR?",
11267 "maintainer",
11268 "spar-bot"
11269 ),
11270 "a question about the block was treated as the block"
11271 );
11272 assert!(
11273 !is_state_comment(&block, "maintainer", "spar-bot"),
11274 "somebody else's comment is not spar's to rewrite"
11275 );
11276 assert!(
11277 !is_state_comment(
11278 &format!("here is what spar writes:\n\n{block}"),
11279 "spar-bot",
11280 "spar-bot"
11281 ),
11282 "the marker has to open the body, not appear in it"
11283 );
11284 assert!(
11285 !is_state_comment(
11286 &format!("{STATE_MARKER}\n{{not json\n-->"),
11287 "spar-bot",
11288 "spar-bot"
11289 ),
11290 "a block that does not parse is not state"
11291 );
11292 assert!(
11293 !is_state_comment(&block, "spar-bot", ""),
11294 "an unknown viewer proves nothing about who wrote it"
11295 );
11296 }
11297
11298 #[test]
11299 fn a_malformed_state_comment_is_none_not_a_panic() {
11300 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
11301 }
11302
11303 #[test]
11304 fn atomic_write_leaves_no_temp_file() {
11305 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
11306 let _ = std::fs::remove_dir_all(&dir);
11307 let path = dir.join("state").join("pr-7.json");
11308 write_json_atomic(&path, &state()).unwrap();
11309 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
11310 .unwrap()
11311 .flatten()
11312 .filter_map(|e| e.file_name().to_str().map(str::to_string))
11313 .collect();
11314 assert_eq!(vec!["pr-7.json".to_string()], files);
11315 let _ = std::fs::remove_dir_all(&dir);
11316 }
11317
11318 #[test]
11319 fn atomic_write_overwrites_rather_than_accumulating() {
11320 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
11321 let _ = std::fs::remove_dir_all(&dir);
11322 let path = dir.join("pr-7.json");
11323 for round in 1..4 {
11324 let mut s = state();
11325 s.round = round;
11326 write_json_atomic(&path, &s).unwrap();
11327 }
11328 let back: PersistedState =
11329 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11330 assert_eq!(3, back.round);
11331 let _ = std::fs::remove_dir_all(&dir);
11332 }
11333
11334 #[test]
11335 fn style_from_env_defaults_to_enforcing() {
11336 std::env::remove_var("SPAR_BAN_EM_DASH");
11337 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
11338 let style = style_from_env();
11339 assert!(style.ban_em_dash && style.ban_ai_attribution);
11340 assert!(
11341 !style.terse,
11342 "the commit filter must not truncate a commit message"
11343 );
11344 }
11345
11346 #[test]
11351 fn a_push_denial_is_read_from_either_transport() {
11352 let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
11353 fatal: Could not read from remote repository.\n\n\
11354 Please make sure you have the correct access rights\n\
11355 and the repository exists.";
11356 let https = "remote: Permission to example/project.git denied to someone.\n\
11357 fatal: unable to access 'https://github.com/example/project.git/': The \
11358 requested URL returned error: 403";
11359 let denied = Some("Permission to example/project.git denied to someone.");
11360 assert_eq!(denied, push_denial(ssh));
11361 assert_eq!(denied, push_denial(https));
11362
11363 let stale = "To github.com:example/project.git\n \
11364 ! [rejected] HEAD -> issue-7 (stale info)\n\
11365 error: failed to push some refs to 'github.com:example/project.git'";
11366 assert_eq!(None, push_denial(stale));
11367
11368 let err = push_error(
11369 &PushTarget::Origin,
11370 "issue-7",
11371 &crate::error::SparError::new(ssh),
11372 );
11373 assert_eq!(
11374 "could not push to origin/issue-7. Permission to example/project.git denied to \
11375 someone.",
11376 err.to_string()
11377 );
11378 }
11379
11380 #[cfg(unix)]
11384 fn denied_fixture(
11385 tag: &str,
11386 fork_when_denied: bool,
11387 ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11388 use std::os::unix::fs::PermissionsExt;
11389 use std::sync::atomic::{AtomicU32, Ordering};
11390 static NEXT: AtomicU32 = AtomicU32::new(0);
11391 let id = NEXT.fetch_add(1, Ordering::Relaxed);
11392 let root =
11393 std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11394 let origin = root.join("origin.git");
11395 let fork = root.join("fork.git");
11396 let work = root.join("work");
11397 for dir in [&origin, &fork, &work] {
11398 std::fs::create_dir_all(dir).unwrap();
11399 }
11400 test_git(&origin, &["init", "--bare", "-b", "main"]);
11401 test_git(&fork, &["init", "--bare", "-b", "main"]);
11402 test_git(&work, &["init", "-b", "main"]);
11403 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11404 test_git(&work, &["config", "user.name", "spar test"]);
11405 test_git(&work, &["config", "commit.gpgsign", "false"]);
11406 std::fs::write(work.join("README.md"), "seed\n").unwrap();
11407 test_git(&work, &["add", "."]);
11408 test_git(&work, &["commit", "-m", "seed"]);
11409 test_git(
11410 &work,
11411 &["remote", "add", "origin", origin.to_str().unwrap()],
11412 );
11413 test_git(&work, &["push", "-q", "origin", "main"]);
11414 let hook = origin.join("hooks").join("pre-receive");
11416 std::fs::write(
11417 &hook,
11418 "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11419 )
11420 .unwrap();
11421 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11422 test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11423 std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11424 test_git(&work, &["add", "."]);
11425 test_git(&work, &["commit", "-m", "Fix it"]);
11426
11427 let mut text =
11428 String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11429 if !fork_when_denied {
11430 text.push_str("[loop]\nfork_when_denied = false\n");
11431 }
11432 let cfg = crate::config::parse(&text).unwrap();
11433 let repo = Repo::open(&work, &cfg).unwrap();
11434 repo.fork
11436 .set(Fork {
11437 owner: "someone".into(),
11438 full_name: "someone/project".into(),
11439 url: fork.to_str().unwrap().into(),
11440 })
11441 .unwrap();
11442 (ReviewFixture { root }, repo, work, origin, fork)
11443 }
11444
11445 #[cfg(unix)]
11446 #[test]
11447 fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11448 let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11449 let first = test_git(&work, &["rev-parse", "HEAD"]);
11450
11451 let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11452
11453 assert_eq!("someone:issue-7", target.head("issue-7"));
11454 assert_eq!(
11455 first.trim(),
11456 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11457 );
11458 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11459 .trim()
11460 .is_empty());
11461 assert_eq!(
11462 WriteSummary {
11463 attempted: 1,
11464 failed: 0
11465 },
11466 repo.write_summary(),
11467 "a refusal the fork answered is not a failed write"
11468 );
11469
11470 std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11473 test_git(&work, &["commit", "-qam", "Fix it again"]);
11474 repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11475 .unwrap();
11476 let second = test_git(&work, &["rev-parse", "HEAD"]);
11477 assert_eq!(
11478 second.trim(),
11479 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11480 );
11481 }
11482
11483 #[cfg(unix)]
11487 #[test]
11488 fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11489 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11490 test_git(
11491 &work,
11492 &[
11493 "push",
11494 "-q",
11495 fork.to_str().unwrap(),
11496 "main:refs/heads/issue-7",
11497 ],
11498 );
11499 let head = test_git(&work, &["rev-parse", "HEAD"]);
11500
11501 repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11502
11503 assert_eq!(
11504 head.trim(),
11505 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11506 );
11507 assert_eq!(0, repo.write_summary().failed);
11508 }
11509
11510 #[cfg(unix)]
11511 #[test]
11512 fn work_only_the_fork_has_is_not_published_over() {
11513 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11514 test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11515 std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11516 test_git(&work, &["add", "."]);
11517 test_git(&work, &["commit", "-qm", "Somebody's work"]);
11518 test_git(
11519 &work,
11520 &[
11521 "push",
11522 "-q",
11523 fork.to_str().unwrap(),
11524 "elsewhere:refs/heads/issue-7",
11525 ],
11526 );
11527 let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11528 test_git(&work, &["checkout", "-q", "issue-7"]);
11529
11530 let err = repo
11531 .publish_new_branch(&work, "issue-7", "main")
11532 .unwrap_err()
11533 .to_string();
11534
11535 assert!(
11536 err.contains(
11537 "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11538 ),
11539 "{err}"
11540 );
11541 assert!(err.contains("--delete issue-7"), "{err}");
11542 assert_eq!(
11543 theirs.trim(),
11544 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11545 );
11546 assert_eq!(
11547 WriteSummary {
11548 attempted: 1,
11549 failed: 1
11550 },
11551 repo.write_summary()
11552 );
11553 }
11554
11555 #[test]
11557 fn a_rejected_push_names_the_ref_it_rejected() {
11558 let stale = "To github.com:example/project.git\n \
11559 ! [rejected] HEAD -> issue-7 (stale info)\n\
11560 error: failed to push some refs to 'github.com:example/project.git'";
11561
11562 let err = push_error(
11563 &PushTarget::Origin,
11564 "issue-7",
11565 &crate::error::SparError::new(stale),
11566 )
11567 .to_string();
11568
11569 assert!(
11570 err.starts_with(
11571 "could not push to origin/issue-7. [rejected] HEAD -> issue-7 (stale info)"
11572 ),
11573 "{err}"
11574 );
11575 }
11576
11577 #[test]
11578 fn a_fork_is_reached_by_whatever_reaches_origin() {
11579 let cases = [
11580 (
11581 "git@github.com:example/project.git",
11582 Some("git@github.com:someone/project.git"),
11583 ),
11584 (
11586 "github.com:example/project.git",
11587 Some("github.com:someone/project.git"),
11588 ),
11589 (
11591 "git@github-work:example/project.git",
11592 Some("git@github-work:someone/project.git"),
11593 ),
11594 (
11596 "ssh://git@ssh.github.com:443/example/project.git",
11597 Some("ssh://git@ssh.github.com:443/someone/project.git"),
11598 ),
11599 (
11600 "https://github.com/example/project",
11601 Some("https://github.com/someone/project"),
11602 ),
11603 (
11604 "https://ghe.example.com/scm/example/project.git/",
11605 Some("https://ghe.example.com/scm/someone/project.git"),
11606 ),
11607 (
11608 "git@host:/srv/example/project.git",
11609 Some("git@host:/srv/someone/project.git"),
11610 ),
11611 ("/srv/git/project.git", None),
11612 ("https://github.com/project", None),
11613 ("", None),
11614 ];
11615 for (origin, want) in cases {
11616 assert_eq!(
11617 want.map(str::to_string),
11618 fork_url(origin, "someone/project"),
11619 "{origin}"
11620 );
11621 }
11622 }
11623
11624 #[test]
11625 fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11626 let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11627 "ssh_url":"git@github.com:someone/project.git",
11628 "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11629 let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11630 "sshUrl":"git@github.com:someone/project.git",
11631 "url":"https://github.com/someone/project"}]"#;
11632
11633 let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11634 assert_eq!("git@github-work:someone/project.git", fork.url);
11635 assert_eq!("someone", fork.owner);
11636 assert_eq!("someone/project", fork.full_name);
11637 let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11638 .unwrap()
11639 .unwrap();
11640 assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11641 assert_eq!(
11642 None,
11643 fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11644 );
11645
11646 let fork = fork_from_api(made, "https://github.com/project").unwrap();
11649 assert_eq!("https://github.com/someone/project.git", fork.url);
11650 let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11651 assert_eq!("git@github.com:someone/project.git", fork.url);
11652
11653 assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11654 assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11655 }
11656
11657 #[test]
11658 fn only_the_viewers_own_fork_can_be_pushed_to() {
11659 let pr = |owner: &str, name: &str| PrView {
11660 number: 9,
11661 url: String::new(),
11662 title: String::new(),
11663 body: String::new(),
11664 head_ref_name: "issue-7".into(),
11665 base_ref_name: "main".into(),
11666 state: "OPEN".into(),
11667 closing_issues_references: Vec::new(),
11668 is_cross_repository: true,
11669 head_repository_owner: Some(crate::model::Author {
11670 login: owner.into(),
11671 }),
11672 head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11673 };
11674 let origin = "git@github.com:example/project.git";
11675
11676 assert_eq!(
11677 Some(PushTarget::Fork(Fork {
11678 owner: "Someone".into(),
11679 full_name: "Someone/project".into(),
11680 url: "git@github.com:Someone/project.git".into(),
11681 })),
11682 own_fork_target(&pr("Someone", "project"), "someone", origin)
11683 );
11684 assert_eq!(
11685 None,
11686 own_fork_target(&pr("other", "project"), "someone", origin)
11687 );
11688 assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11689 assert_eq!(
11690 None,
11691 own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11692 );
11693 let mut unnamed = pr("someone", "project");
11694 unnamed.head_repository_owner = None;
11695 assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11696 }
11697
11698 #[cfg(unix)]
11701 #[test]
11702 fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11703 let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11704 test_git(
11705 &work,
11706 &[
11707 "push",
11708 "-q",
11709 fork.to_str().unwrap(),
11710 "issue-7:refs/heads/issue-7",
11711 ],
11712 );
11713 let published = test_git(&work, &["rev-parse", "issue-7"]);
11714 test_git(&work, &["checkout", "-q", "main"]);
11715 let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11716 let pr = PrView {
11717 number: 31,
11718 url: String::new(),
11719 title: String::new(),
11720 body: String::new(),
11721 head_ref_name: "issue-7".into(),
11722 base_ref_name: "main".into(),
11723 state: "OPEN".into(),
11724 closing_issues_references: Vec::new(),
11725 is_cross_repository: true,
11726 head_repository_owner: None,
11727 head_repository: None,
11728 };
11729
11730 let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11731
11732 assert_eq!("issue-7", branch);
11733 assert_eq!(
11734 published.trim(),
11735 test_git(&path, &["rev-parse", "HEAD"]).trim()
11736 );
11737 std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11738 test_git(&path, &["commit", "-qam", "Answer a comment"]);
11739 repo.push_to(&path, &target, &branch, Some(published.trim()))
11740 .unwrap();
11741 assert_eq!(
11742 test_git(&path, &["rev-parse", "HEAD"]).trim(),
11743 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11744 );
11745 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11746 .trim()
11747 .is_empty());
11748 }
11749
11750 #[cfg(unix)]
11751 #[test]
11752 fn with_forking_off_a_refused_push_says_why_and_how() {
11753 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11754
11755 let err = repo
11756 .publish_new_branch(&work, "issue-7", "main")
11757 .unwrap_err()
11758 .to_string();
11759
11760 assert!(
11761 err.contains("Permission to example/project.git denied to someone."),
11762 "{err}"
11763 );
11764 assert!(err.contains("fork_when_denied = true"), "{err}");
11765 assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11766 .trim()
11767 .is_empty());
11768 assert_eq!(
11769 WriteSummary {
11770 attempted: 1,
11771 failed: 1
11772 },
11773 repo.write_summary()
11774 );
11775 }
11776}
11777
11778#[cfg(test)]
11779mod comment_page_tests {
11780 use super::*;
11781
11782 #[test]
11783 fn a_single_merged_array_is_read() {
11784 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11785 assert_eq!(2, pages.len());
11786 assert_eq!(Some(2), pages[1]["id"].as_i64());
11787 }
11788
11789 #[test]
11790 fn concatenated_pages_from_an_older_gh_are_read_too() {
11791 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11792 assert_eq!(2, pages.len());
11793 }
11794
11795 #[test]
11799 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11800 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11801 let pages = parse_comment_pages(text);
11802 assert_eq!(2, pages.len(), "{pages:?}");
11803 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11804 }
11805
11806 #[test]
11807 fn empty_output_is_no_comments_not_a_panic() {
11808 assert!(parse_comment_pages("").is_empty());
11809 assert!(parse_comment_pages(" ").is_empty());
11810 assert!(parse_comment_pages("[]").is_empty());
11811 }
11812
11813 #[test]
11814 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11815 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11816 }
11817
11818 #[test]
11819 fn a_write_postcheck_rejects_truncated_comment_pages() {
11820 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11821 assert!(
11822 error.to_string().contains("unexpected comment pages"),
11823 "{error}"
11824 );
11825 }
11826
11827 #[test]
11828 fn a_write_postcheck_rejects_empty_or_non_array_output() {
11829 assert!(try_parse_comment_pages("").is_err());
11830 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11831 assert!(try_parse_comment_pages("[]").is_ok());
11832 }
11833
11834 #[test]
11835 fn state_is_found_in_the_last_matching_comment() {
11836 let payload = |round: u32| {
11837 format!(
11838 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11839 )
11840 };
11841 let text = serde_json::to_string(&serde_json::json!([
11842 {"id": 1, "body": payload(1)},
11843 {"id": 2, "body": "looks good to me"},
11844 {"id": 3, "body": payload(5)},
11845 ]))
11846 .unwrap();
11847 let pages = parse_comment_pages(&text);
11848 let last = pages
11849 .iter()
11850 .rev()
11851 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11852 .unwrap();
11853 assert_eq!(5, last.round);
11854 }
11855}
11856
11857#[cfg(test)]
11858mod linked_pr_tests {
11859 use super::*;
11860
11861 const REAL_PAYLOAD: &str = r#"[
11866 {"number":14252,"title":"fix: reject leading-dash branch names",
11867 "url":"https://github.com/cli/cli/pull/14252",
11868 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11869 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11870 "url":"https://github.com/cli/cli/issues/14238"}]},
11871 {"number":14217,"title":"another change",
11872 "url":"https://github.com/cli/cli/pull/14217",
11873 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11874 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11875 "url":"https://github.com/cli/cli/issues/9761"}]},
11876 {"number":14200,"title":"unlinked work",
11877 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11878 ]"#;
11879
11880 #[test]
11881 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11882 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11883 assert_eq!(14252, pr.number);
11884 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11885 }
11886
11887 #[test]
11888 fn the_right_pr_is_picked_out_of_several() {
11889 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11890 }
11891
11892 #[test]
11893 fn an_issue_nobody_is_working_on_finds_nothing() {
11894 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11895 }
11896
11897 #[test]
11898 fn an_unlinked_pr_is_never_matched() {
11899 for issue in [14200, 0, 1] {
11901 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11902 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11903 }
11904 }
11905 }
11906
11907 #[test]
11908 fn empty_or_broken_output_is_none_rather_than_a_panic() {
11909 assert!(find_linked_pr("", 1).is_none());
11910 assert!(find_linked_pr("[]", 1).is_none());
11911 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11912 assert!(find_linked_pr("[{\"number\":", 1).is_none());
11913 }
11914
11915 #[test]
11922 fn an_unreadable_list_is_not_an_empty_list() {
11923 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11924 assert!(linked_pr_rows("[{\"number\":").is_none());
11925 assert!(linked_pr_rows("").is_none());
11926
11927 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11928 assert!(empty.is_empty());
11929 assert!(pick_linked_pr(&empty, 14238).is_none());
11930
11931 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11932 assert_eq!(3, listed.len());
11933 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11934 }
11935
11936 #[test]
11938 fn pr_view_reads_the_cross_repository_flag() {
11939 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11940 "baseRefName":"main","state":"OPEN",
11941 "closingIssuesReferences":[],"isCrossRepository":true}"#;
11942 let pr: PrView = serde_json::from_str(json).unwrap();
11943 assert!(pr.is_cross_repository);
11944 assert!(pr.is_open());
11945
11946 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11947 assert!(
11948 !serde_json::from_str::<PrView>(&same_repo)
11949 .unwrap()
11950 .is_cross_repository
11951 );
11952 }
11953}
11954
11955#[cfg(test)]
11956mod min_number_tests {
11957 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11963 let mut numbers: Vec<i64> = open.to_vec();
11964 numbers.sort_unstable();
11965 if min_number > 0 {
11966 numbers.retain(|n| *n >= min_number);
11967 }
11968 numbers.truncate(limit);
11969 numbers
11970 }
11971
11972 #[test]
11973 fn the_floor_is_applied_before_the_cap_not_after() {
11974 let open = [12, 13, 14, 480, 481, 482];
11975 assert_eq!(vec![480, 481], pick(&open, 2, 480));
11976 assert!(!pick(&open, 2, 480).is_empty());
11979 }
11980
11981 #[test]
11982 fn no_floor_keeps_the_old_behaviour() {
11983 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11984 }
11985
11986 #[test]
11987 fn the_floor_is_inclusive() {
11988 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11989 }
11990
11991 #[test]
11992 fn a_floor_above_everything_open_yields_nothing() {
11993 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11994 }
11995}
11996
11997#[cfg(test)]
11998mod review_record_tests {
11999 use super::*;
12000 use serde_json::json;
12001
12002 #[test]
12003 fn prune_state_knows_which_files_belong_to_a_pull_request() {
12004 assert_eq!(Some(12), state_file_number("pr-12.json"));
12005 assert_eq!(Some(12), state_file_number("review-12.json"));
12006 assert_eq!(Some(12), state_file_number("checkin-12.json"));
12007 for shared in [
12008 "spend.json",
12009 "implementors.json",
12010 "contested.json",
12011 "listen.json",
12012 "pr-.json",
12013 "pr-12.json.tmp",
12014 "pr-x.json",
12015 ] {
12016 assert_eq!(None, state_file_number(shared), "{shared}");
12017 }
12018 }
12019
12020 #[test]
12021 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
12022 let reviews = vec![
12023 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
12024 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
12025 ];
12026 assert!(has_viewer_review(&reviews, "me", &["the body"], "aaa"));
12027 assert!(!has_viewer_review(&reviews, "me", &["other"], "aaa"));
12028 assert!(!has_viewer_review(&reviews, "alice", &["the body"], "aaa"));
12029 }
12030
12031 #[test]
12035 fn a_review_that_landed_with_either_body_is_not_sent_again() {
12036 let brief = vec![
12037 json!({"user": {"login": "me"}, "body": "brief", "commit_id": "aaa", "state": "COMMENTED"}),
12038 ];
12039 let full = vec![
12040 json!({"user": {"login": "me"}, "body": "full", "commit_id": "aaa", "state": "CHANGES_REQUESTED"}),
12041 ];
12042 for landed in [&brief, &full] {
12043 assert!(has_viewer_review(landed, "me", &["brief", "full"], "aaa"));
12044 assert!(has_viewer_review(landed, "me", &["full", "brief"], "aaa"));
12045 assert!(!has_viewer_review(landed, "me", &["brief", "full"], "bbb"));
12046 }
12047 assert!(!has_viewer_review(&brief, "me", &["full"], "aaa"));
12048 assert!(!has_viewer_review(&full, "me", &[], "aaa"));
12049 }
12050
12051 #[test]
12055 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
12056 let older = vec![
12057 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
12058 ];
12059 assert!(!has_viewer_review(&older, "me", &["the body"], "bbb"));
12060 let dismissed = vec![
12061 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
12062 ];
12063 assert!(!has_viewer_review(&dismissed, "me", &["the body"], "bbb"));
12064 }
12065
12066 #[test]
12067 fn a_response_splits_into_status_headers_and_body() {
12068 let text = "HTTP/2.0 200 OK\r\nX-Poll-Interval: 60\r\nLast-Modified: Tue, 20 Oct 2026 10:00:00 GMT\r\nContent-Type: application/json\r\n\r\n[{\"id\": \"1\"}]\n";
12069 let resp = parse_api_response(text).expect("a response");
12070 assert_eq!(200, resp.status);
12071 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
12072 assert_eq!(
12073 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
12074 resp.headers.get("last-modified")
12075 );
12076 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
12077
12078 let not_modified =
12079 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
12080 assert_eq!(304, not_modified.status);
12081 assert!(not_modified.body.is_empty());
12082
12083 assert_eq!(None, parse_api_response(""));
12084 assert_eq!(None, parse_api_response("gh: command not found\n"));
12085 }
12086
12087 #[test]
12088 fn review_requests_are_read_off_the_timeline_with_who_and_when() {
12089 let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
12090 {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
12091 {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
12092 {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
12093 {}
12094 ]}}}}})
12095 .to_string();
12096 let made = parse_review_requests(&text).expect("requests");
12097 assert_eq!(2, made.len());
12098 assert_eq!("2026-10-20T10:00:00Z", made[0].at);
12099 assert_eq!("viewer", made[0].who.login);
12100 assert!(made[0].who.slug.is_empty());
12101 assert_eq!("2026-10-20T11:00:00Z", made[1].at);
12102 assert!(made[1].who.login.is_empty());
12103 assert_eq!("example/ours", made[1].who.slug);
12104
12105 let none =
12106 json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
12107 assert_eq!(
12108 Some(0),
12109 parse_review_requests(&none.to_string()).map(|m| m.len())
12110 );
12111 assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
12112 assert!(parse_review_requests("gh: not json").is_none());
12113 }
12114
12115 #[test]
12116 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
12117 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
12118 let error = crate::error::SparError::call_failed("gh: boom");
12119 assert!(
12120 reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(landed)).is_ok()
12121 );
12122
12123 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
12124 let err = reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(missing))
12125 .unwrap_err();
12126 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
12127
12128 let unreadable = Err(crate::error::SparError::new("no network"));
12129 let err =
12130 reconcile_review_post(5, &["the body"], "me", "aaa", error, unreadable).unwrap_err();
12131 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
12132 assert!(err.to_string().contains("could not be verified"), "{err}");
12133 }
12134 #[test]
12135 fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
12136 let comments = vec![
12137 InlineComment {
12138 path: "src/a.rs".into(),
12139 line: 12,
12140 start_line: None,
12141 body: "one".into(),
12142 },
12143 InlineComment {
12144 path: "src/b.rs".into(),
12145 line: 9,
12146 start_line: Some(4),
12147 body: "two".into(),
12148 },
12149 ];
12150 assert_eq!(
12151 json!({
12152 "commit_id": "abc",
12153 "event": "COMMENT",
12154 "body": "the body",
12155 "comments": [
12156 {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
12157 {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
12158 "start_side": "RIGHT", "body": "two"},
12159 ],
12160 }),
12161 review_payload("abc", "COMMENT", "the body", &comments)
12162 );
12163 }
12164
12165 #[test]
12166 fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
12167 let diff = r#"diff --git a/src/a.rs b/src/a.rs
12168index 1111111..2222222 100644
12169--- a/src/a.rs
12170+++ b/src/a.rs
12171@@ -1,3 +1,4 @@ fn x
12172 one
12173+++ an added line that looks like a header
12174 two
12175 three
12176@@ -20 +21,2 @@
12177-old
12178+new
12179+more
12180@@ -30,1 +32,0 @@
12181-gone
12182diff --git a/gone.rs b/gone.rs
12183deleted file mode 100644
12184--- a/gone.rs
12185+++ /dev/null
12186@@ -1,2 +0,0 @@
12187-a
12188-b
12189diff --git a/old.rs b/renamed.rs
12190similarity index 90%
12191rename from old.rs
12192rename to renamed.rs
12193--- a/old.rs
12194+++ b/renamed.rs
12195@@ -5,0 +6,1 @@
12196+inserted
12197diff --git a/with space.rs b/with space.rs
12198--- a/with space.rs
12199+++ b/with space.rs
12200@@ -2 +2 @@
12201-x
12202+y
12203diff --git "a/sp\tace.rs" "b/sp\tace.rs"
12204--- "a/sp\tace.rs"
12205+++ "b/sp\tace.rs"
12206@@ -1 +1 @@
12207-x
12208+y
12209"#;
12210 let hunks = new_side_hunks(diff);
12211 assert_eq!(
12212 Hunks::from([
12213 ("renamed.rs".to_string(), vec![(6, 6)]),
12214 ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
12215 ("with space.rs".to_string(), vec![(2, 2)]),
12216 ]),
12217 hunks
12218 );
12219 }
12220
12221 #[test]
12222 fn only_a_refusal_is_final_enough_to_send_a_review_again() {
12223 let error = |text: &str| crate::error::SparError::new(text);
12224 assert!(refused(&error(
12225 "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
12226 )));
12227 assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
12228 assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
12229 assert!(!refused(&error("command timed out after 300s")));
12230 }
12231
12232 #[test]
12233 fn a_hunk_header_with_its_counts_left_out_counts_one() {
12234 assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
12235 assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
12236 assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
12237 assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
12238 }
12239}