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
1267const ASSIGNMENT_FIELDS: &str = "
1271 number
1272 title
1273 assignees(first: 100) { nodes { login } }
1274 timelineItems(itemTypes: [ASSIGNED_EVENT], last: 20) {
1275 nodes {
1276 ... on AssignedEvent {
1277 createdAt
1278 assignee {
1279 ... on User { login }
1280 ... on Bot { login }
1281 ... on Mannequin { login }
1282 }
1283 }
1284 }
1285 }";
1286
1287fn assigned_issues_query() -> String {
1291 format!(
1292 "query($owner: String!, $repo: String!, $login: String!) {{
1293 repository(owner: $owner, name: $repo) {{
1294 issues(states: OPEN, filterBy: {{assignee: $login}}, first: 100, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{
1295 nodes {{{ASSIGNMENT_FIELDS}
1296 }}
1297 }}
1298 }}
1299}}"
1300 )
1301}
1302
1303fn issue_assignments_query() -> String {
1305 format!(
1306 "query($owner: String!, $repo: String!, $number: Int!) {{
1307 repository(owner: $owner, name: $repo) {{
1308 issue(number: $number) {{{ASSIGNMENT_FIELDS}
1309 }}
1310 }}
1311}}"
1312 )
1313}
1314
1315fn assigned_issue(node: &Value) -> Option<crate::model::AssignedIssue> {
1317 let text_at = |node: &Value, path: &str| {
1318 node.pointer(path)
1319 .and_then(Value::as_str)
1320 .unwrap_or_default()
1321 .to_string()
1322 };
1323 let nodes = |path: &str| {
1324 node.pointer(path)
1325 .and_then(Value::as_array)
1326 .cloned()
1327 .unwrap_or_default()
1328 };
1329 Some(crate::model::AssignedIssue {
1330 number: node.pointer("/number")?.as_i64()?,
1331 title: text_at(node, "/title"),
1332 assignees: nodes("/assignees/nodes")
1333 .iter()
1334 .map(|a| text_at(a, "/login"))
1335 .filter(|login| !login.is_empty())
1336 .collect(),
1337 assigned: nodes("/timelineItems/nodes")
1340 .iter()
1341 .map(|e| (text_at(e, "/createdAt"), text_at(e, "/assignee/login")))
1342 .filter(|(at, who)| !at.is_empty() && !who.is_empty())
1343 .collect(),
1344 })
1345}
1346
1347pub fn parse_assigned_issues(text: &str) -> Option<Vec<crate::model::AssignedIssue>> {
1350 let v: Value = serde_json::from_str(text).ok()?;
1351 let nodes = v.pointer("/data/repository/issues/nodes")?.as_array()?;
1352 Some(nodes.iter().filter_map(assigned_issue).collect())
1353}
1354
1355pub fn parse_issue_assignments(text: &str) -> Option<crate::model::AssignedIssue> {
1358 let v: Value = serde_json::from_str(text).ok()?;
1359 assigned_issue(v.pointer("/data/repository/issue")?)
1360}
1361
1362pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1366 let stem = name.strip_suffix(".json")?;
1367 ["pr-", "review-", "checkin-"]
1368 .iter()
1369 .find_map(|prefix| stem.strip_prefix(prefix))
1370 .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1371 .and_then(|digits| digits.parse().ok())
1372}
1373
1374fn has_viewer_review(reviews: &[Value], viewer: &str, bodies: &[&str], commit: &str) -> bool {
1382 reviews.iter().any(|review| {
1383 let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1384 let standing = !matches!(
1385 field("state").to_uppercase().as_str(),
1386 "DISMISSED" | "PENDING"
1387 );
1388 review
1389 .get("user")
1390 .and_then(|u| u.get("login"))
1391 .and_then(Value::as_str)
1392 .is_some_and(|login| crate::comments::same_login(login, viewer))
1393 && bodies.contains(&field("body"))
1394 && field("commit_id").eq_ignore_ascii_case(commit)
1395 && standing
1396 })
1397}
1398
1399fn reconcile_review_post(
1400 number: i64,
1401 bodies: &[&str],
1402 viewer: &str,
1403 commit: &str,
1404 post_error: crate::error::SparError,
1405 reviews: Result<Vec<Value>>,
1406) -> Result<()> {
1407 match reviews {
1408 Ok(reviews) if has_viewer_review(&reviews, viewer, bodies, commit) => Ok(()),
1409 Ok(_) => Err(post_error),
1410 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1411 "could not submit a review on #{number}. {} The result could not be verified: {}",
1412 post_error.last_line(),
1413 read_error.last_line()
1414 ))),
1415 }
1416}
1417
1418fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1423 let comments: Vec<Value> = comments
1424 .iter()
1425 .map(|c| {
1426 let mut row = serde_json::json!({
1427 "path": c.path,
1428 "line": c.line,
1429 "side": "RIGHT",
1430 "body": c.body,
1431 });
1432 if let Some(start) = c.start_line {
1433 row["start_line"] = serde_json::json!(start);
1434 row["start_side"] = serde_json::json!("RIGHT");
1435 }
1436 row
1437 })
1438 .collect();
1439 serde_json::json!({
1440 "commit_id": commit,
1441 "event": event,
1442 "body": body,
1443 "comments": comments,
1444 })
1445}
1446
1447fn refused(e: &crate::error::SparError) -> bool {
1451 e.to_string().contains("(HTTP 422)")
1452}
1453
1454pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1458
1459fn new_side_hunks(diff: &str) -> Hunks {
1466 let mut out = Hunks::new();
1467 let mut path: Option<String> = None;
1468 let (mut old_left, mut new_left) = (0u32, 0u32);
1469 for line in diff.lines() {
1470 if old_left > 0 || new_left > 0 {
1471 match line.as_bytes().first() {
1472 Some(b'+') => new_left = new_left.saturating_sub(1),
1473 Some(b'-') => old_left = old_left.saturating_sub(1),
1474 Some(b'\\') => {}
1475 _ => {
1476 old_left = old_left.saturating_sub(1);
1477 new_left = new_left.saturating_sub(1);
1478 }
1479 }
1480 continue;
1481 }
1482 if line.starts_with("diff --git ") {
1483 path = None;
1484 } else if let Some(rest) = line.strip_prefix("+++ ") {
1485 let rest = if rest.contains(' ') {
1488 rest.strip_suffix('\t').unwrap_or(rest)
1489 } else {
1490 rest
1491 };
1492 path = rest
1493 .strip_prefix("b/")
1494 .filter(|p| !p.is_empty())
1495 .map(str::to_string);
1496 } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1497 old_left = old_len;
1498 new_left = len;
1499 if let (Some(path), true) = (&path, len > 0 && start > 0) {
1500 out.entry(path.clone())
1501 .or_default()
1502 .push((start, start + len - 1));
1503 }
1504 }
1505 }
1506 out
1507}
1508
1509fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1511 let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1512 let (old, new) = ranges.split_once(" +")?;
1513 let range = |s: &str| -> Option<(u32, u32)> {
1514 match s.split_once(',') {
1515 Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1516 None => Some((s.parse().ok()?, 1)),
1517 }
1518 };
1519 Some((range(old)?, range(new)?))
1520}
1521
1522fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1523 comments.iter().any(|comment| {
1524 comment
1525 .get("body")
1526 .and_then(Value::as_str)
1527 .is_some_and(|seen| seen == body)
1528 })
1529}
1530
1531fn reconcile_comment_post(
1532 number: i64,
1533 body: &str,
1534 post_error: crate::error::SparError,
1535 comments: Result<Vec<Value>>,
1536) -> Result<()> {
1537 match comments {
1538 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1539 Ok(_) => Err(post_error),
1540 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1541 "could not comment on #{number}. {} The result could not be verified: {}",
1542 post_error.last_line(),
1543 read_error.last_line()
1544 ))),
1545 }
1546}
1547
1548fn reconcile_issue_edit(
1549 number: i64,
1550 wanted: &str,
1551 edit_error: crate::error::SparError,
1552 observed: Result<String>,
1553) -> Result<()> {
1554 match observed {
1555 Ok(body) if body == wanted => Ok(()),
1556 Ok(_) => Err(spar_err!(
1557 "could not rewrite the body of #{number}. {}",
1558 edit_error.last_line()
1559 )),
1560 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1561 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1562 edit_error.last_line(),
1563 read_error.last_line()
1564 ))),
1565 }
1566}
1567
1568fn issue_url_has_number(url: &str) -> bool {
1569 url.trim()
1570 .rsplit('/')
1571 .next()
1572 .and_then(|tail| tail.parse::<i64>().ok())
1573 .is_some_and(|number| number > 0)
1574}
1575
1576fn reconcile_issue_creation(
1577 title: &str,
1578 created: Result<String>,
1579 found: Result<Option<ExistingIssue>>,
1580) -> Result<String> {
1581 match (created, found) {
1582 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1583 (_, Ok(Some(issue))) => Ok(issue.url),
1584 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1585 "issue creation reported success but no matching issue was found for {title:?}"
1586 ))),
1587 (Err(create), Ok(None)) => Err(spar_err!(
1588 "could not file issue {title:?}. {}",
1589 create.last_line()
1590 )),
1591 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1592 "issue creation reported success for {title:?}, but it could not be verified. {}",
1593 check.last_line()
1594 ))),
1595 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1596 "could not file issue {title:?}. {} The result could not be verified: {}",
1597 create.last_line(),
1598 check.last_line()
1599 ))),
1600 }
1601}
1602
1603fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1604 if output.trim().is_empty() {
1605 return Ok(None);
1606 }
1607 for line in output.lines() {
1608 let mut fields = line.split_whitespace();
1609 let oid = fields.next().unwrap_or_default();
1610 let name = fields.next().unwrap_or_default();
1611 if name == remote_ref && !oid.is_empty() {
1612 return Ok(Some(oid.to_string()));
1613 }
1614 }
1615 Err(spar_err!(
1616 "origin returned an unexpected ref listing for {remote_ref}"
1617 ))
1618}
1619
1620fn reconcile_failed_split_push(
1621 branch: &str,
1622 push_error: crate::error::SparError,
1623 local: Result<String>,
1624 remote: Result<String>,
1625) -> std::result::Result<(), SplitPushError> {
1626 let remote_ref = format!("refs/heads/{branch}");
1627 match (local, remote) {
1628 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1629 Ok(Some(oid)) if oid == local.trim() => Ok(()),
1630 Ok(_) => Err(SplitPushError::new(
1631 format!(
1632 "could not create origin/{branch}. {} The remote branch is absent or points \
1633 somewhere else. Nothing was overwritten.",
1634 push_error.last_line()
1635 ),
1636 false,
1637 )),
1638 Err(check) => Err(SplitPushError::new(
1639 format!(
1640 "could not confirm whether origin/{branch} was created. {} The remote result \
1641 could not be verified: {}",
1642 push_error.last_line(),
1643 check.last_line()
1644 ),
1645 true,
1646 )),
1647 },
1648 (local, remote) => {
1649 let check = match (local, remote) {
1650 (Err(local), Err(remote)) => format!(
1651 "the local commit could not be read: {}; origin could not be read: {}",
1652 local.last_line(),
1653 remote.last_line()
1654 ),
1655 (Err(local), _) => {
1656 format!("the local commit could not be read: {}", local.last_line())
1657 }
1658 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1659 _ => unreachable!(),
1660 };
1661 Err(SplitPushError::new(
1662 format!(
1663 "could not confirm whether origin/{branch} was created. {} The result could \
1664 not be verified because {check}",
1665 push_error.last_line()
1666 ),
1667 true,
1668 ))
1669 }
1670 }
1671}
1672
1673impl Repo {
1674 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1675 let root =
1676 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1677 let inside = proc::run_str(
1680 &["git", "rev-parse", "--is-inside-work-tree"],
1681 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1682 )
1683 .unwrap_or_default();
1684 if inside.trim() != "true" {
1685 bail!("not a git repository: {}", root.display());
1686 }
1687 let repo = Self {
1688 root,
1689 style: cfg.style.clone(),
1690 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1691 state_store: cfg.loop_cfg.state_store,
1692 followups: cfg.loop_cfg.followups,
1693 drafts: cfg.loop_cfg.drafts,
1694 fork_when_denied: cfg.loop_cfg.fork_when_denied,
1695 setup: cfg.loop_cfg.setup.clone(),
1696 isolate_reads: cfg.loop_cfg.worktrees,
1697 keep_worktrees: cfg.loop_cfg.keep_worktrees,
1698 fork: OnceLock::new(),
1699 viewer: OnceLock::new(),
1700 checkpoints: Mutex::new(BTreeMap::new()),
1701 writes: WriteStats::default(),
1702 };
1703 repo.self_exclude();
1704 Ok(repo)
1705 }
1706
1707 fn self_exclude(&self) {
1715 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1716 let git_dir = git_dir.trim();
1717 if git_dir.is_empty() {
1718 return;
1719 }
1720 let path = Path::new(git_dir).join("info").join("exclude");
1721 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1722
1723 let wanted = [
1728 format!("/{WORKTREE_DIR}/"),
1729 format!("/{STATE_DIR}/"),
1730 format!("{RECOVERY_MARKER_PREFIX}*"),
1731 ];
1732 let missing: Vec<&String> = wanted
1733 .iter()
1734 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1735 .collect();
1736 if missing.is_empty() {
1737 return;
1738 }
1739
1740 use std::io::Write;
1741 if let Some(parent) = path.parent() {
1742 let _ = std::fs::create_dir_all(parent);
1743 }
1744 let mut block = String::new();
1745 if !existing.is_empty() && !existing.ends_with('\n') {
1746 block.push('\n');
1747 }
1748 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1749 for line in missing {
1750 block.push_str(line);
1751 block.push('\n');
1752 }
1753 if let Ok(mut file) = std::fs::OpenOptions::new()
1754 .create(true)
1755 .append(true)
1756 .open(&path)
1757 {
1758 let _ = file.write_all(block.as_bytes());
1759 }
1760 }
1761
1762 pub fn root(&self) -> &Path {
1763 &self.root
1764 }
1765
1766 pub(crate) fn write_summary(&self) -> WriteSummary {
1767 WriteSummary {
1768 attempted: self.writes.attempted.load(Ordering::Relaxed),
1769 failed: self.writes.failed.load(Ordering::Relaxed),
1770 }
1771 }
1772
1773 pub(crate) fn record_write<T, E>(
1774 &self,
1775 result: std::result::Result<T, E>,
1776 ) -> std::result::Result<T, E> {
1777 self.record_write_outcome(result.is_err());
1778 result
1779 }
1780
1781 pub(crate) fn record_failed_write<T, E>(
1782 &self,
1783 result: std::result::Result<T, E>,
1784 ) -> std::result::Result<T, E> {
1785 if result.is_err() {
1786 self.record_write_outcome(true);
1787 }
1788 result
1789 }
1790
1791 fn record_write_outcome(&self, failed: bool) {
1792 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1793 if failed {
1794 self.writes.failed.fetch_add(1, Ordering::Relaxed);
1795 }
1796 }
1797
1798 pub fn clean(&self, text: &str) -> Result<String> {
1804 let out = style::scrub(text, &self.style);
1805 let bad = style::violations(&out, &self.style);
1806 if !bad.is_empty() {
1807 bail!(
1808 "style gate could not clean text ({}): {}",
1809 bad.join(", "),
1810 style::clip(&out, 300)
1811 );
1812 }
1813 Ok(out)
1814 }
1815
1816 pub fn clean_body(&self, text: &str) -> Result<String> {
1818 self.clean(&style::body(text, &self.style))
1819 }
1820
1821 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1823 self.clean(&style::issue_body(text, &self.style))
1824 }
1825
1826 pub fn clean_title(&self, text: &str) -> Result<String> {
1837 Ok(style::title(&self.clean(text)?, &self.style))
1838 }
1839
1840 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1841 let title = self.record_failed_write(self.clean_title(text))?;
1842 if title.trim().is_empty() {
1843 return self.record_failed_write(Err(spar_err!(
1844 "nothing left of the title after cleaning it"
1845 )));
1846 }
1847 Ok(title)
1848 }
1849
1850 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1851 if self.followups == Followups::Issues {
1852 self.clean_nonempty_title_for_write(text)
1853 } else {
1854 self.clean_title(text)
1855 }
1856 }
1857
1858 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1861 ExecOpts::new()
1862 .cwd(cwd.unwrap_or(&self.root))
1863 .check(check)
1864 .timeout_secs(600)
1865 }
1866
1867 pub fn git(&self, args: &[&str]) -> Result<String> {
1868 self.git_at(None, args)
1869 }
1870
1871 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1872 let argv = git_without_maintenance_argv(args);
1873 proc::run(&argv, &self.git_opts(cwd, true))
1874 }
1875
1876 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1882 let argv = git_without_automation_argv(args);
1883 proc::run(
1884 &argv,
1885 &self.git_opts(Some(cwd), true).stop_descendants(true),
1886 )
1887 }
1888
1889 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1890 let argv = git_without_automation_argv(args);
1891 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1892 .map(|output| output.ok())
1893 }
1894
1895 pub fn git_try(&self, args: &[&str]) -> String {
1897 self.git_try_at(None, args)
1898 }
1899
1900 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1901 let argv = git_without_maintenance_argv(args);
1902 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1903 }
1904
1905 pub fn default_branch(&self, configured: &str) -> String {
1908 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1909 match refname.trim().rsplit('/').next() {
1910 Some(name) if !name.is_empty() => name.to_string(),
1911 _ => configured.to_string(),
1912 }
1913 }
1914
1915 pub fn branch_for_issue(&self, issue: i64) -> String {
1923 format!("{}issue-{issue}", self.branch_prefix)
1924 }
1925
1926 pub fn branch_for_pr(&self, number: i64) -> String {
1927 format!("{}pr-{number}", self.branch_prefix)
1928 }
1929
1930 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1939 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1940 }
1941
1942 fn ledger_path(&self) -> PathBuf {
1943 self.root.join(STATE_DIR).join("branches.json")
1944 }
1945
1946 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1947 std::fs::read_to_string(self.ledger_path())
1948 .ok()
1949 .and_then(|text| serde_json::from_str(&text).ok())
1950 .unwrap_or_default()
1951 }
1952
1953 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1954 let mut data = self.known_branches();
1955 data.insert(
1956 branch.to_string(),
1957 BranchRecord {
1958 kind: kind.to_string(),
1959 number,
1960 },
1961 );
1962 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1963 logdim!("could not record branch {branch}: {e}");
1964 }
1965 }
1966
1967 pub fn forget_branch(&self, branch: &str) {
1968 let mut data = self.known_branches();
1969 if data.remove(branch).is_none() {
1970 return;
1971 }
1972 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1973 logdim!("could not update the branch record: {e}");
1974 }
1975 }
1976
1977 fn worktree_path(&self, name: &str) -> PathBuf {
1980 self.root.join(WORKTREE_DIR).join(name)
1981 }
1982
1983 fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
1990 let argv = self.setup.argv();
1991 if argv.is_empty() {
1992 return Ok(());
1993 }
1994 let command = self.setup.describe();
1995 logdim!("setting up the {label}: {command}");
1996 let out = proc::exec(
1997 &argv,
1998 &ExecOpts::new()
1999 .cwd(path)
2000 .check(false)
2001 .timeout_secs(SETUP_TIMEOUT_SECS)
2002 .stop_descendants(true),
2003 )
2004 .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
2005 if !out.ok() {
2006 let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
2007 bail!(
2008 "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
2009 work in it.\n{}",
2010 path.display(),
2011 style::clip(text.trim(), 4000)
2012 );
2013 }
2014 Ok(())
2015 }
2016
2017 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
2019 let branch = self.branch_for_issue(issue);
2020 let path = self.worktree_path(&format!("issue-{issue}"));
2021
2022 self.refuse_issue_branch_rebuild(issue, base)?;
2023 self.refuse_dirty_worktree(
2024 &path,
2025 &format!("worktree for issue #{issue}"),
2026 Disposable::BuildOutput,
2027 )?;
2028
2029 if !self.branch_deletion_is_safe(&branch)? {
2030 bail!(
2031 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
2032 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2033 before retrying."
2034 );
2035 }
2036
2037 if !self.remove_worktree_at(&path)? {
2038 bail!(
2039 "the existing worktree for issue #{issue} could not be removed safely. Its \
2040 branch was kept."
2041 );
2042 }
2043 if !self.delete_branch_if_safe(&branch)? {
2044 bail!(
2045 "the existing branch {branch} changed or remained checked out while its \
2046 worktree was being rebuilt. It was kept."
2047 );
2048 }
2049
2050 if let Some(parent) = path.parent() {
2051 std::fs::create_dir_all(parent)
2052 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2053 }
2054
2055 let path_str = path.display().to_string();
2056 let remote_start = format!("origin/{base}");
2057 let created = self
2058 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
2059 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
2060
2061 created.map_err(|e| {
2064 spar_err!(
2065 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
2066 and does `origin` exist?",
2067 e.last_line()
2068 )
2069 })?;
2070 self.record_branch(&branch, "issue", issue);
2071 self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
2072 Ok((path, branch))
2073 }
2074
2075 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
2082 let branch = self.branch_for_issue(issue);
2083 let base_remote_ref = format!("refs/heads/{base}");
2084 let base_tracking_ref = format!("refs/remotes/origin/{base}");
2085 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
2086 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
2087 .map_err(|e| {
2088 spar_err!(
2089 "could not refresh origin/{base} before checking issue #{issue}: {}",
2090 e.last_line()
2091 )
2092 })?;
2093
2094 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
2095 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
2096 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
2097 bail!(
2098 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
2099 pull request accounts for them. Rebuilding it would force push over that \
2100 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
2101 it, or delete it with `git push origin --delete {branch}` if the remote \
2102 branch is no longer needed."
2103 );
2104 }
2105 }
2106
2107 if self.fork_when_denied && self.origin_refuses_push() {
2110 match self.existing_fork() {
2111 Ok(Some(fork)) => {
2112 self.fork_branch_lease(&fork, &branch, base)?;
2113 }
2114 Ok(None) => {}
2115 Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
2116 }
2117 }
2118
2119 let local_ref = format!("refs/heads/{branch}");
2120 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2121 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
2122 let recorded_pr = self
2123 .known_branches()
2124 .get(&branch)
2125 .is_some_and(|record| record.kind == "pr");
2126 let preserved = ahead == 0
2127 || if recorded_pr {
2128 self.local_branch_is_preserved(&branch)?
2129 } else {
2130 self.pull_request_holds(&branch, &local_ref, base)
2131 };
2132 if !preserved {
2133 let listed = self
2134 .commit_lines(&self.root, &local_ref, base)
2135 .iter()
2136 .map(|line| format!(" {line}"))
2137 .collect::<Vec<_>>()
2138 .join("\n");
2139 bail!(
2140 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
2141 no pull request preserves them. Rebuilding it would delete the only copy.\n\
2142 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
2143 it, or delete it with `git branch -D {branch}` if it is stale."
2144 );
2145 }
2146 }
2147 Ok(())
2148 }
2149
2150 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2151 let live_ref = format!("refs/heads/{branch}");
2152 let tracking_ref = format!("refs/remotes/origin/{branch}");
2153 let listed = self
2154 .git(&["ls-remote", "--heads", "origin", &live_ref])
2155 .map_err(|e| {
2156 spar_err!(
2157 "could not verify whether origin/{branch} still exists: {}",
2158 e.last_line()
2159 )
2160 })?;
2161
2162 if remote_head_oid(&listed, &live_ref)?.is_some() {
2163 let refspec = format!("+{live_ref}:{tracking_ref}");
2164 self.git(&["fetch", "--no-tags", "origin", &refspec])
2165 .map_err(|e| {
2166 spar_err!(
2167 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2168 e.last_line()
2169 )
2170 })?;
2171 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2172 bail!("origin/{branch} was fetched but its tracking ref is missing");
2173 }
2174 return Ok(Some(tracking_ref));
2175 }
2176
2177 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2178 return Ok(None);
2179 }
2180 let expected = self
2181 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2182 .trim()
2183 .to_string();
2184 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2185 .map_err(|e| {
2186 spar_err!(
2187 "could not discard stale origin/{branch} tracking ref safely: {}",
2188 e.last_line()
2189 )
2190 })?;
2191 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2192 bail!(
2193 "origin/{branch} changed while its stale tracking ref was being removed. It was \
2194 kept."
2195 );
2196 }
2197 Ok(None)
2198 }
2199
2200 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2209 self.prs_for_branch(branch)
2210 .iter()
2211 .any(|pr| self.pr_head_holds(pr.number, refname, base))
2212 }
2213
2214 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2215 let head = format!("refs/spar/pr-head/{number}");
2216 let refspec = format!("+refs/pull/{number}/head:{head}");
2217 if self.git(&["fetch", "origin", &refspec]).is_err() {
2218 return false;
2219 }
2220 let held = self.commits_held_by(refname, base, &head);
2221 self.git_try(&["update-ref", "-d", &head]);
2222 held
2223 }
2224
2225 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2226 let argv = vec![
2227 "git".to_string(),
2228 "merge-base".to_string(),
2229 "--is-ancestor".to_string(),
2230 older.to_string(),
2231 newer.to_string(),
2232 ];
2233 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2234 match out.code {
2235 0 => Ok(true),
2236 1 => Ok(false),
2237 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2238 }
2239 }
2240
2241 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2242 let head = format!("refs/spar/pr-head/{number}");
2243 let refspec = format!("+refs/pull/{number}/head:{head}");
2244 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2245 spar_err!(
2246 "could not verify the immutable head of PR #{number}: {}",
2247 e.last_line()
2248 )
2249 })?;
2250 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2251 self.git_try(&["update-ref", "-d", &head]);
2252 held
2253 }
2254
2255 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2256 let text = self.gh(&[
2257 "pr",
2258 "list",
2259 "--head",
2260 branch,
2261 "--state",
2262 "all",
2263 "--json",
2264 "number,url,title",
2265 ])?;
2266 serde_json::from_str(text.trim())
2267 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2268 }
2269
2270 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2271 let branch_ref = format!("refs/heads/{branch}");
2272 if record.kind == "pr" {
2273 return self.pr_head_contains_checked(record.number, &branch_ref);
2274 }
2275 let prs = self.branch_prs_checked(branch)?;
2276 if prs.is_empty() {
2277 return Ok(false);
2278 }
2279 for pr in prs {
2280 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2281 return Ok(true);
2282 }
2283 }
2284 Ok(false)
2285 }
2286
2287 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2288 let local_ref = format!("refs/heads/{branch}");
2289 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2290 return Ok(true);
2291 }
2292 let oid = self
2293 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2294 .trim()
2295 .to_string();
2296 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2297 if !durable_tip {
2298 let remote_ref = format!("refs/heads/{branch}");
2299 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2300 durable_tip = remote.lines().any(|line| {
2301 line.split_whitespace()
2302 .next()
2303 .is_some_and(|remote_oid| remote_oid == oid)
2304 });
2305 }
2306 if !durable_tip {
2307 if let Some(record) = self.known_branches().get(branch) {
2308 durable_tip = self.branch_is_preserved_checked(branch, record)?;
2309 }
2310 }
2311 if !durable_tip {
2312 return Ok(false);
2313 }
2314 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2315 }
2316
2317 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2321 let local_ref = format!("refs/heads/{branch}");
2322 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2323 return Ok(true);
2324 }
2325 let expected = self
2326 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2327 .trim()
2328 .to_string();
2329 if !self.branch_deletion_is_safe(branch)? {
2330 return Ok(false);
2331 }
2332 let checked_out = self
2333 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2334 .lines()
2335 .any(|line| line == format!("branch {local_ref}"));
2336 if checked_out {
2337 return Ok(false);
2338 }
2339 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2340 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2341 }
2342
2343 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2344 let local_ref = review_ref(number);
2345 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2346 return Ok(true);
2347 }
2348 let oid = self
2349 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2350 .trim()
2351 .to_string();
2352 if !self.pr_head_contains_checked(number, &local_ref)? {
2353 return Ok(false);
2354 }
2355 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2356 }
2357
2358 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2359 let local_ref = review_ref(number);
2360 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2361 return Ok(true);
2362 }
2363 let expected = self
2364 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2365 .trim()
2366 .to_string();
2367 if !self.review_ref_deletion_is_safe(number)? {
2368 return Ok(false);
2369 }
2370 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2371 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2372 }
2373
2374 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2378 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2379 self.git_try(&["rev-list", "--count", &range, "--not", other])
2380 .trim()
2381 == "0"
2382 }
2383
2384 pub fn worktree_remove(&self, issue: i64) -> bool {
2385 let path = self.worktree_path(&format!("issue-{issue}"));
2386 match self.remove_worktree_at(&path) {
2387 Ok(removed) => removed,
2388 Err(error) => {
2389 logdim!(
2390 "kept {} because removal did not reach a confirmed quiet point: {}",
2391 path.display(),
2392 error.last_line()
2393 );
2394 false
2395 }
2396 }
2397 }
2398
2399 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2404 let wanted = std::fs::canonicalize(path)
2405 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2406 if wanted != path {
2411 return Ok(false);
2412 }
2413 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2414 let raw = PathBuf::from(value.trim());
2415 let joined = if raw.is_absolute() {
2416 raw
2417 } else {
2418 cwd.join(raw)
2419 };
2420 std::fs::canonicalize(&joined)
2421 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2422 };
2423 let expected =
2424 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2425 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2426 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2427 let expected = resolve(&self.root, &expected)?;
2428 let actual = resolve(path, &actual)?;
2429 let top = resolve(path, &top)?;
2430 Ok(expected == actual && top == wanted)
2431 }
2432
2433 fn remove_worktree_at_with_force(
2439 &self,
2440 path: &Path,
2441 force: bool,
2442 disposable: Disposable,
2443 ) -> Result<bool> {
2444 let existed = path.exists();
2445 if path.exists() {
2446 match self.worktree_belongs_to_repo(path) {
2447 Ok(true) => {}
2448 Ok(false) => {
2449 logdim!(
2450 "kept {} because it is not a worktree owned by this repository",
2451 path.display()
2452 );
2453 return Ok(false);
2454 }
2455 Err(e) => {
2456 logdim!(
2457 "kept {} because its worktree ownership could not be verified: {}",
2458 path.display(),
2459 e.last_line()
2460 );
2461 return Ok(false);
2462 }
2463 }
2464 if !force {
2465 match self.has_recoverable_work(path, disposable) {
2466 Ok(true) => {
2467 logdim!(
2468 "kept {} because it contains recoverable files or repository state",
2469 path.display()
2470 );
2471 return Ok(false);
2472 }
2473 Err(e) => {
2474 logdim!(
2475 "kept {} because its recoverable state could not be checked: {}",
2476 path.display(),
2477 e.last_line()
2478 );
2479 return Ok(false);
2480 }
2481 Ok(false) => {}
2482 }
2483 }
2484 }
2485 let path_str = path.display().to_string();
2491 let command_ok = if force || disposable == Disposable::ReviewOutput {
2492 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2493 } else {
2494 self.git_try_without_automation(&["worktree", "remove", &path_str])?
2495 };
2496 Ok((command_ok || !existed) && !path.exists())
2497 }
2498
2499 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2500 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2501 }
2502
2503 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2505 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2506 }
2507
2508 fn remove_worktree_at_force(&self, path: &Path) -> bool {
2510 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2511 Ok(removed) => removed,
2512 Err(error) => {
2513 logdim!(
2514 "kept {} because removal did not reach a confirmed quiet point: {}",
2515 path.display(),
2516 error.last_line()
2517 );
2518 false
2519 }
2520 }
2521 }
2522
2523 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2528 if path.exists() && !self.worktree_belongs_to_repo(path)? {
2529 bail!(
2530 "{} is not a worktree owned by this repository, so it was kept",
2531 path.display()
2532 );
2533 }
2534 if path.exists() && self.has_recoverable_work(path, disposable)? {
2535 bail!(
2536 "the verified worktree at {} contains recoverable files or repository state. It \
2537 was kept.",
2538 path.display()
2539 );
2540 }
2541 let path_str = path.display().to_string();
2542 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2543 .map_err(|e| {
2544 e.with_message(format!(
2545 "could not remove the verified worktree at {}: {}. It was kept.",
2546 path.display(),
2547 e.last_line()
2548 ))
2549 })?;
2550 Ok(!path.exists())
2551 }
2552
2553 fn refuse_dirty_worktree(
2554 &self,
2555 path: &Path,
2556 label: &str,
2557 disposable: Disposable,
2558 ) -> Result<()> {
2559 if !path.is_dir() {
2560 return Ok(());
2561 }
2562 let has_files = std::fs::read_dir(path)
2563 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2564 .next()
2565 .is_some();
2566 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2567 spar_err!(
2568 "could not verify whether the existing {label} at {} belongs to this repository, \
2569 so it was kept: {}",
2570 path.display(),
2571 e.last_line()
2572 )
2573 })?;
2574 if !owned {
2575 if has_files {
2576 bail!(
2577 "the existing {label} at {} is not a worktree owned by \
2578 this repository. Refusing to remove it.",
2579 path.display()
2580 );
2581 }
2582 return Ok(());
2583 }
2584 if !path.join(".git").exists() {
2585 if has_files {
2586 bail!(
2587 "the existing {label} at {} is not a readable Git worktree and is not empty. \
2588 Refusing to remove it.",
2589 path.display()
2590 );
2591 }
2592 return Ok(());
2593 }
2594 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2595 spar_err!(
2596 "could not verify whether the existing {label} at {} is clean, so it was kept: \
2597 {}",
2598 path.display(),
2599 e.last_line()
2600 )
2601 })?;
2602 if dirty {
2603 bail!(
2604 "the existing {label} contains uncommitted changes or ignored files at {}. \
2605 Rebuilding it would delete those files.\nCommit or recover them before running this \
2606 command again, or use `spar clean --all` if they are not needed.",
2607 path.display()
2608 );
2609 }
2610 Ok(())
2611 }
2612
2613 pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2616 let head = pr.head_ref_name.clone();
2617 if head.trim().is_empty() {
2618 bail!("PR #{} has no head branch to check out", pr.number);
2619 }
2620 let path = self.worktree_path(&format!("pr-{}", pr.number));
2621 let local = self.branch_for_pr(pr.number);
2622
2623 let unfetched = |e: crate::error::SparError| {
2624 spar_err!(
2625 "could not fetch the branch behind PR #{}: {}",
2626 pr.number,
2627 e.last_line()
2628 )
2629 };
2630 let (start, start_ref, shown) = match target {
2633 PushTarget::Origin => {
2634 self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2635 let start = format!("origin/{head}");
2636 (start.clone(), format!("refs/remotes/origin/{head}"), start)
2637 }
2638 PushTarget::Fork(fork) => {
2641 let start_ref = format!("refs/spar/fork/{head}");
2642 let refspec = format!("+refs/heads/{head}:{start_ref}");
2643 self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2644 .map_err(unfetched)?;
2645 (start_ref.clone(), start_ref, target.describe(&head))
2646 }
2647 };
2648 let local_ref = format!("refs/heads/{local}");
2649 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2650 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2651 if unpushed > 0 {
2652 bail!(
2653 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2654 on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2655 {} and push or recover those commits before running this command again.",
2656 pr.number,
2657 path.display()
2658 );
2659 }
2660 }
2661 self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2662 self.refuse_dirty_worktree(
2663 &path,
2664 &format!("worktree for PR #{}", pr.number),
2665 Disposable::BuildOutput,
2666 )?;
2667 if !self.branch_deletion_is_safe(&local)? {
2668 bail!(
2669 "the existing branch {local} has a tip or reflog-only commit that no surviving \
2670 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2671 before retrying."
2672 );
2673 }
2674 if !self.remove_worktree_at(&path)? {
2675 bail!(
2676 "the existing worktree for PR #{} could not be removed safely. Its branch was \
2677 kept.",
2678 pr.number
2679 );
2680 }
2681 if !self.delete_branch_if_safe(&local)? {
2682 bail!(
2683 "the existing branch {local} changed or remained checked out while the PR \
2684 worktree was being rebuilt. It was kept."
2685 );
2686 }
2687
2688 let path_str = path.display().to_string();
2689 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2690 self.record_branch(&local, "pr", pr.number);
2691 self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2692 Ok((path, head))
2693 }
2694
2695 fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2698 if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2699 return Ok(());
2700 }
2701 let current =
2702 self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2703 if current.trim() != branch
2704 || self.has_uncommitted_changes(path)?
2705 || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2706 {
2707 return Ok(());
2708 }
2709 static NEXT: AtomicUsize = AtomicUsize::new(0);
2710 let (name, retained) = loop {
2711 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2712 let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2713 let retained = self.worktree_path(&name);
2714 if !retained
2715 .try_exists()
2716 .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2717 && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2718 {
2719 break (name, retained);
2720 }
2721 };
2722 self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2725 if let Err(error) = self.git_at_without_automation(
2726 &self.root,
2727 &[
2728 "worktree",
2729 "move",
2730 &path.display().to_string(),
2731 &retained.display().to_string(),
2732 ],
2733 ) {
2734 let rollback =
2737 self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2738 let detail = rollback
2739 .err()
2740 .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2741 .unwrap_or_default();
2742 bail!(
2743 "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2744 retained.display(),
2745 error.last_line()
2746 );
2747 }
2748 logdim!(
2749 "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2750 retained.display()
2751 );
2752 Ok(())
2753 }
2754
2755 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2765 let path = self.worktree_path(&format!("review-{number}"));
2766 let local_ref = review_ref(number);
2767 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2768
2769 self.refuse_review_worktree_changes(number)?;
2770
2771 if let Some(parent) = path.parent() {
2772 std::fs::create_dir_all(parent)
2773 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2774 }
2775 if !self.remove_review_worktree_at(&path)? {
2780 bail!(
2781 "the existing review worktree for PR #{number} could not be removed safely. Its \
2782 reference was kept."
2783 );
2784 }
2785
2786 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2787 spar_err!(
2788 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2789 every pull request, so this usually means the number is wrong or `origin` does \
2790 not point at the repository the PR is on.",
2791 e.last_line()
2792 )
2793 })?;
2794
2795 let path_str = path.display().to_string();
2796 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2797 self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2798 Ok(path)
2799 }
2800
2801 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2802 let path = self.worktree_path(&format!("review-{number}"));
2803 if !path.is_dir() {
2804 return Ok(());
2805 }
2806 let local_ref = review_ref(number);
2807 if !self.worktree_belongs_to_repo(&path)? {
2808 return Ok(());
2809 }
2810 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2811 bail!(
2812 "the existing review worktree for PR #{number} has no recorded head at \
2813 {local_ref}. Refusing to rebuild {}.",
2814 path.display()
2815 );
2816 }
2817 let worktree_head = self.head_oid_checked(&path)?;
2818 let recorded_head = self
2819 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2820 .trim()
2821 .to_string();
2822 if worktree_head != recorded_head {
2823 bail!(
2824 "the existing review worktree for PR #{number} has a local commit that is not on \
2825 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2826 {} before retrying.",
2827 path.display()
2828 );
2829 }
2830 self.report_recovery_markers(&path);
2831 self.refuse_dirty_worktree(
2832 &path,
2833 &format!("review worktree for PR #{number}"),
2834 Disposable::ReviewOutput,
2835 )?;
2836 Ok(())
2837 }
2838
2839 fn report_recovery_markers(&self, path: &Path) {
2847 let Ok(entries) = std::fs::read_dir(path) else {
2848 return;
2849 };
2850 for entry in entries.flatten() {
2851 let name = entry.file_name();
2852 let Some(name) = name.to_str() else { continue };
2853 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2854 continue;
2855 }
2856 match std::fs::read_to_string(entry.path()) {
2857 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2858 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2859 }
2860 }
2861 }
2862
2863 pub fn read_checkout(&self, label: &str) -> Result<ReadCheckout<'_>> {
2869 let shared = || ReadCheckout {
2870 repo: self,
2871 path: self.root.clone(),
2872 checkpoint: None,
2873 };
2874 if !self.isolate_reads {
2875 return Ok(shared());
2876 }
2877 let Some(head) = resolve_optional_commit(&self.root, "HEAD")? else {
2878 return Ok(shared());
2879 };
2880 static NEXT: AtomicUsize = AtomicUsize::new(0);
2881 let path = loop {
2882 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2883 let path = self.worktree_path(&format!(
2884 "{READ_CHECKOUT_PREFIX}{}-{serial}",
2885 std::process::id()
2886 ));
2887 if !path
2888 .try_exists()
2889 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2890 {
2891 break path;
2892 }
2893 };
2894 if let Some(parent) = path.parent() {
2895 std::fs::create_dir_all(parent)
2896 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2897 }
2898 let path_str = path.display().to_string();
2899 self.git(&["worktree", "add", "--detach", &path_str, &head])
2900 .map_err(|e| {
2901 spar_err!(
2902 "could not check HEAD out for the {label}: {}",
2903 e.last_line()
2904 )
2905 })?;
2906 let ready = self
2907 .set_up_worktree(&path, &format!("read-only checkout for the {label}"))
2908 .and_then(|()| self.worktree_checkpoint(&path));
2909 match ready {
2910 Ok(checkpoint) => Ok(ReadCheckout {
2911 repo: self,
2912 path,
2913 checkpoint: Some(checkpoint),
2914 }),
2915 Err(e) => {
2916 self.discard_read_checkout(&path);
2918 Err(e)
2919 }
2920 }
2921 }
2922
2923 fn release_read_checkout(&self, path: &Path, checkpoint: &WorktreeCheckpoint) {
2930 if self.keep_worktrees {
2931 logdim!("kept the read-only checkout at {}", path.display());
2932 return;
2933 }
2934 if has_recovery_marker(path) {
2935 logdim!(
2936 "kept the read-only checkout at {} for recovery",
2937 path.display()
2938 );
2939 return;
2940 }
2941 if let Err(e) = self.require_unchanged_worktree(path, checkpoint, "read-only checkout") {
2942 logwarn!("{e}");
2943 return;
2944 }
2945 self.discard_read_checkout(path);
2946 }
2947
2948 fn discard_read_checkout(&self, path: &Path) {
2949 match self.remove_worktree_at_checked(path, Disposable::ReviewOutput) {
2950 Ok(true) => {}
2951 Ok(false) => logdim!(
2952 "kept the read-only checkout at {} because it was still there after removal",
2953 path.display()
2954 ),
2955 Err(e) => logdim!(
2956 "kept the read-only checkout at {}: {}",
2957 path.display(),
2958 e.last_line()
2959 ),
2960 }
2961 }
2962
2963 pub fn worktree_for_split(
2976 &self,
2977 parent: i64,
2978 index: usize,
2979 start: &str,
2980 ) -> Result<(PathBuf, String)> {
2981 let slot = self.free_split_slot(parent, index)?;
2982 let branch = format!("{}{slot}", self.branch_prefix);
2983 let path = self.worktree_path(&slot);
2984
2985 if let Some(dir) = path.parent() {
2986 std::fs::create_dir_all(dir)
2987 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2988 }
2989 self.refuse_dirty_worktree(
2990 &path,
2991 &format!("worktree for part {index} of PR #{parent}"),
2992 Disposable::BuildOutput,
2993 )?;
2994 if !self.remove_worktree_at(&path)? {
2998 bail!(
2999 "the existing worktree for part {index} of PR #{parent} could not be removed \
3000 safely. No branch was created."
3001 );
3002 }
3003
3004 let path_str = path.display().to_string();
3005 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
3006 .map_err(|e| {
3007 spar_err!(
3008 "could not create a worktree for part {index} of #{parent}. {}",
3009 e.last_line()
3010 )
3011 })?;
3012 self.record_branch(&branch, "split", parent);
3015 self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
3016 Ok((path, branch))
3017 }
3018
3019 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
3026 for attempt in 1..=SPLIT_SLOTS {
3027 let slot = split_slot(parent, index, attempt);
3028 let branch = format!("{}{slot}", self.branch_prefix);
3029 self.git_try(&["fetch", "origin", &branch]);
3030 if !self.rev_exists(&self.root, &branch)
3031 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
3032 {
3033 return Ok(slot);
3034 }
3035 }
3036 bail!(
3037 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
3038 names are all taken. Inspect the existing branches and child pull requests. Finish \
3039 recording the earlier split, or remove every retained local worktree and branch, \
3040 child pull request, and remote split branch before starting over.",
3041 self.branch_for_split(parent, index)
3042 )
3043 }
3044
3045 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
3051 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
3052 Ok(!self
3053 .git(&["ls-remote", "--heads", "origin", &pattern])?
3054 .trim()
3055 .is_empty())
3056 }
3057
3058 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
3065 match self.branch_deletion_is_safe(branch) {
3066 Ok(true) => {}
3067 Ok(false) => {
3068 logdim!(
3069 "kept {branch} and {} because no surviving ref preserves its tip",
3070 dir.display()
3071 );
3072 return;
3073 }
3074 Err(error) => {
3075 logdim!(
3076 "kept {branch} and {} because preservation could not be verified: {}",
3077 dir.display(),
3078 error.last_line()
3079 );
3080 return;
3081 }
3082 }
3083 match self.remove_worktree_at(dir) {
3084 Ok(true) => match self.delete_branch_if_safe(branch) {
3085 Ok(true) => self.forget_branch(branch),
3086 Ok(false) => {
3087 logdim!("kept {branch} because its tip or reflog changed before deletion")
3088 }
3089 Err(error) => logdim!(
3090 "kept {branch} because deletion safety could not be rechecked: {}",
3091 error.last_line()
3092 ),
3093 },
3094 Ok(false) => {}
3095 Err(error) => logdim!(
3096 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
3097 dir.display(),
3098 error.last_line()
3099 ),
3100 }
3101 }
3102
3103 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
3109 let record = self.known_branches().get(branch).cloned();
3110 if record.is_none_or(|record| record.kind != "split") {
3111 logdim!("kept {branch} because no split branch record proves ownership");
3112 return false;
3113 }
3114 let local_ref = format!("refs/heads/{branch}");
3115 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
3116 Ok(value) => value.trim().to_string(),
3117 Err(error) => {
3118 logdim!(
3119 "kept {branch} because its tip could not be checked: {}",
3120 error.last_line()
3121 );
3122 return false;
3123 }
3124 };
3125 if expected != disposable_head {
3126 logdim!("kept {branch} because it moved beyond the disposable slice");
3127 return false;
3128 }
3129 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
3130 Ok(true) => {}
3131 Ok(false) => {
3132 logdim!(
3133 "kept {branch} because its reflog contains work outside the disposable slice"
3134 );
3135 return false;
3136 }
3137 Err(error) => {
3138 logdim!(
3139 "kept {branch} because its reflog could not be checked: {}",
3140 error.last_line()
3141 );
3142 return false;
3143 }
3144 }
3145 match self.head_oid_checked(dir) {
3146 Ok(head) if head == disposable_head => {}
3147 Ok(_) => {
3148 logdim!(
3149 "kept {branch} and {} because the worktree moved beyond the disposable slice",
3150 dir.display()
3151 );
3152 return false;
3153 }
3154 Err(error) => {
3155 logdim!(
3156 "kept {branch} and {} because its head could not be checked: {}",
3157 dir.display(),
3158 error.last_line()
3159 );
3160 return false;
3161 }
3162 }
3163 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
3164 Ok(true) => {}
3165 Ok(false) => return false,
3166 Err(error) => {
3167 logdim!(
3168 "kept {branch} and {} because the disposable slice could not be verified: {}",
3169 dir.display(),
3170 error.last_line()
3171 );
3172 return false;
3173 }
3174 }
3175 if let Err(error) =
3176 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
3177 {
3178 logdim!(
3179 "kept {branch} because its exact disposable tip could not be deleted: {}",
3180 error.last_line()
3181 );
3182 return false;
3183 }
3184 match self.exact_ref_exists_checked(&self.root, &local_ref) {
3185 Ok(false) => {
3186 self.forget_branch(branch);
3187 true
3188 }
3189 Ok(true) => {
3190 logdim!("kept {branch} because its ref still exists after deletion");
3191 false
3192 }
3193 Err(error) => {
3194 logdim!(
3195 "kept the branch record for {branch} because deletion could not be verified: {}",
3196 error.last_line()
3197 );
3198 false
3199 }
3200 }
3201 }
3202
3203 pub fn release_review_worktree(&self, number: i64) {
3204 let path = self.worktree_path(&format!("review-{number}"));
3205 match self.review_ref_deletion_is_safe(number) {
3206 Ok(true) => {}
3207 Ok(false) => {
3208 logdim!(
3209 "kept {} because no surviving ref preserves its review history",
3210 path.display()
3211 );
3212 return;
3213 }
3214 Err(error) => {
3215 logdim!(
3216 "kept {} because review history could not be verified: {}",
3217 path.display(),
3218 error.last_line()
3219 );
3220 return;
3221 }
3222 }
3223 match self.remove_worktree_at(&path) {
3224 Ok(true) => match self.delete_review_ref_if_safe(number) {
3225 Ok(true) => {}
3226 Ok(false) => logdim!(
3227 "kept {} because its review history changed before deletion",
3228 review_ref(number)
3229 ),
3230 Err(error) => logdim!(
3231 "kept {} because deletion safety could not be rechecked: {}",
3232 review_ref(number),
3233 error.last_line()
3234 ),
3235 },
3236 Ok(false) => {}
3237 Err(error) => logdim!(
3238 "kept {} because removal did not reach a confirmed quiet point: {}",
3239 path.display(),
3240 error.last_line()
3241 ),
3242 }
3243 }
3244
3245 pub(crate) fn release_review_worktree_checked(
3248 &self,
3249 number: i64,
3250 checkpoint: &WorktreeCheckpoint,
3251 ) -> Result<()> {
3252 let path = self.worktree_path(&format!("review-{number}"));
3253 self.require_unchanged_worktree(
3254 &path,
3255 checkpoint,
3256 &format!("review worktree for PR #{number}"),
3257 )?;
3258 if !self.review_ref_deletion_is_safe(number)? {
3259 bail!(
3260 "the review reference for PR #{number} has reflog-only recovery history. The \
3261 worktree and reference were kept."
3262 );
3263 }
3264 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3265 bail!(
3266 "the verified review worktree at {} could not be removed, so its reference was \
3267 kept",
3268 path.display()
3269 );
3270 }
3271 if !self.delete_review_ref_if_safe(number)? {
3272 bail!(
3273 "the review reference for PR #{number} changed before deletion. The reference was \
3274 kept."
3275 );
3276 }
3277 Ok(())
3278 }
3279
3280 pub fn release_pr_worktree(&self, number: i64) -> bool {
3281 let path = self.worktree_path(&format!("pr-{number}"));
3282 let local = self.branch_for_pr(number);
3283 match self.branch_deletion_is_safe(&local) {
3284 Ok(true) => {}
3285 Ok(false) => {
3286 logdim!(
3287 "kept {local} and {} because no surviving ref preserves its tip",
3288 path.display()
3289 );
3290 return false;
3291 }
3292 Err(error) => {
3293 logdim!(
3294 "kept {local} and {} because preservation could not be verified: {}",
3295 path.display(),
3296 error.last_line()
3297 );
3298 return false;
3299 }
3300 }
3301 match self.remove_worktree_at(&path) {
3302 Ok(true) => match self.delete_branch_if_safe(&local) {
3303 Ok(true) => {
3304 self.forget_branch(&local);
3305 true
3306 }
3307 Ok(false) => {
3308 logdim!("kept {local} because its tip or reflog changed before deletion");
3309 false
3310 }
3311 Err(error) => {
3312 logdim!(
3313 "kept {local} because deletion safety could not be rechecked: {}",
3314 error.last_line()
3315 );
3316 false
3317 }
3318 },
3319 Ok(false) => false,
3320 Err(error) => {
3321 logdim!(
3322 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3323 path.display(),
3324 error.last_line()
3325 );
3326 false
3327 }
3328 }
3329 }
3330
3331 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3342 let remote = format!("origin/{base}");
3343 if self.rev_exists(cwd, &remote) {
3344 return remote;
3345 }
3346 if self.rev_exists(cwd, base) {
3347 logdim!("origin/{base} does not resolve, comparing against local {base}");
3348 return base.to_string();
3349 }
3350 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3351 remote
3352 }
3353
3354 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3355 let spec = format!("{refname}^{{commit}}");
3356 !self
3357 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3358 .trim()
3359 .is_empty()
3360 }
3361
3362 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3363 let range = format!("{}..HEAD", self.base_ref(cwd, base));
3364 !self
3365 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3366 .trim()
3367 .is_empty()
3368 }
3369
3370 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3371 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3372 Ok(found.lines().any(|line| line.trim() == refname))
3373 }
3374
3375 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3376 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3377 count.trim().parse::<usize>().map_err(|e| {
3378 spar_err!(
3379 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3380 count.trim()
3381 )
3382 })
3383 }
3384
3385 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3386 let remote = format!("refs/remotes/origin/{base}");
3387 if self.exact_ref_exists_checked(cwd, &remote)? {
3388 return Ok(remote);
3389 }
3390 let local = format!("refs/heads/{base}");
3391 if self.exact_ref_exists_checked(cwd, &local)? {
3392 return Ok(local);
3393 }
3394 bail!("neither origin/{base} nor local branch {base} resolves")
3395 }
3396
3397 pub(crate) fn commit_count_checked(
3398 &self,
3399 cwd: &Path,
3400 refname: &str,
3401 base: &str,
3402 ) -> Result<usize> {
3403 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3404 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3405 count.trim().parse::<usize>().map_err(|e| {
3406 spar_err!(
3407 "git returned an invalid commit count for {range}: {:?} ({e})",
3408 count.trim()
3409 )
3410 })
3411 }
3412
3413 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3414 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3415 }
3416
3417 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3418 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3419 let head = head.trim().to_string();
3420 if head.is_empty() {
3421 bail!("git returned an empty HEAD for {}", cwd.display());
3422 }
3423 Ok(head)
3424 }
3425
3426 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3431 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3432 self.local_branch_is_preserved(branch.trim())
3433 }
3434
3435 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3438 let known = self.known_branches();
3439 let Some(record) = known.get(branch) else {
3440 return Ok(false);
3441 };
3442 self.branch_is_preserved_checked(branch, record)
3443 }
3444
3445 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3447 has_uncommitted_work(cwd)
3448 }
3449
3450 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3453 repository_has_recoverable_work_with(cwd, true, disposable)
3454 }
3455
3456 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3458 let attributes = attribute_state(cwd)?;
3459 Ok(WorktreeBaseline {
3460 attributes,
3461 ignored_untracked: ignored_untracked_state(cwd)?,
3462 git_state: safe_git_state(cwd)?,
3463 })
3464 }
3465
3466 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3469 let attributes = attribute_state(cwd)?;
3470 Ok(WorktreeCheckpoint {
3471 path: std::fs::canonicalize(cwd)
3472 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3473 attributes,
3474 git_state: safe_git_state(cwd)?,
3475 ignored_untracked: ignored_untracked_state(cwd)?,
3476 })
3477 }
3478
3479 pub(crate) fn set_aside_review_scratch(
3495 &self,
3496 cwd: &Path,
3497 checkpoint: &WorktreeCheckpoint,
3498 number: i64,
3499 ) {
3500 let Ok(after) = probe_again_on_failure(|| ignored_untracked_state(cwd)) else {
3501 return;
3502 };
3503 let created: Vec<PathBuf> = checkpoint
3504 .ignored_untracked
3505 .new_ordinary_paths(&after)
3506 .into_iter()
3507 .filter(|path| {
3508 std::fs::symlink_metadata(cwd.join(path))
3509 .is_ok_and(|meta| meta.is_file() || meta.file_type().is_symlink())
3510 })
3511 .collect();
3512 if created.is_empty() {
3513 return;
3514 }
3515 let shelf = match self.scratch_shelf(number) {
3516 Ok(shelf) => shelf,
3517 Err(e) => {
3518 logdim!("could not make room to move a reviewer's files aside: {e}");
3519 return;
3520 }
3521 };
3522 let mut moved = Vec::new();
3523 for path in created {
3524 let to = shelf.join(&path);
3525 let result = to
3526 .parent()
3527 .map_or(Ok(()), std::fs::create_dir_all)
3528 .and_then(|()| std::fs::rename(cwd.join(&path), &to));
3529 match result {
3530 Ok(()) => moved.push(path),
3531 Err(e) => logdim!(
3532 "could not move {} out of the review checkout: {e}",
3533 cwd.join(&path).display()
3534 ),
3535 }
3536 }
3537 if !moved.is_empty() {
3538 logwarn!(
3539 "PR #{number}: a reviewer left {} file(s) in the review checkout: {}. They were \
3540 moved to {} and the review went on.",
3541 moved.len(),
3542 list_paths(&moved),
3543 shelf.display()
3544 );
3545 }
3546 }
3547
3548 fn scratch_shelf(&self, number: i64) -> Result<PathBuf> {
3551 let scratch = self.root.join(STATE_DIR).join("scratch");
3552 std::fs::create_dir_all(&scratch)
3553 .map_err(|e| spar_err!("could not create {}: {e}", scratch.display()))?;
3554 let stem = format!("pr-{number}-{}", crate::clock::now_secs());
3555 for serial in 0..1000 {
3556 let name = match serial {
3557 0 => stem.clone(),
3558 n => format!("{stem}-{n}"),
3559 };
3560 let shelf = scratch.join(name);
3561 match std::fs::create_dir(&shelf) {
3562 Ok(()) => return Ok(shelf),
3563 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
3564 Err(e) => return Err(spar_err!("could not create {}: {e}", shelf.display())),
3565 }
3566 }
3567 bail!(
3568 "could not choose a free directory under {}",
3569 scratch.display()
3570 )
3571 }
3572
3573 pub(crate) fn require_unchanged_worktree(
3576 &self,
3577 cwd: &Path,
3578 checkpoint: &WorktreeCheckpoint,
3579 label: &str,
3580 ) -> Result<()> {
3581 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3582 crate::error::SparError::uncertain_write(format!(
3583 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3584 cwd.display()
3585 ))
3586 })?;
3587 if resolved != checkpoint.path {
3588 return Err(uncertain_worktree_change(
3589 cwd,
3590 format!(
3591 "the {label} moved from {} to {} during inspection. It was kept.",
3592 checkpoint.path.display(),
3593 resolved.display()
3594 ),
3595 ));
3596 }
3597 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3598 uncertain_worktree_change(
3599 cwd,
3600 format!(
3601 "could not verify attribute files in the {label} at {}: {}. It was kept.",
3602 cwd.display(),
3603 e.last_line()
3604 ),
3605 )
3606 })?;
3607 if attributes != checkpoint.attributes {
3608 return Err(uncertain_worktree_change(
3609 cwd,
3610 format!(
3611 "attribute files in the {label} at {} changed during inspection. It was \
3612 kept for recovery.",
3613 cwd.display()
3614 ),
3615 ));
3616 }
3617 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3618 uncertain_worktree_change(
3619 cwd,
3620 format!(
3621 "could not verify the Git state of the {label} at {}: {}. It was kept.",
3622 cwd.display(),
3623 e.last_line()
3624 ),
3625 )
3626 })?;
3627 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3628 uncertain_worktree_change(
3629 cwd,
3630 format!(
3631 "could not verify untracked files in the {label} at {}: {}. It was kept.",
3632 cwd.display(),
3633 e.last_line()
3634 ),
3635 )
3636 })?;
3637 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3638 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3642 let tracked = checkpoint.git_state.describe_difference(&git_state);
3643 let untracked = (!leavings.condemning.is_empty()).then(|| {
3644 format!(
3645 "untracked file(s) changed: {}",
3646 list_paths(&leavings.condemning)
3647 )
3648 });
3649 let Some(reason) = join_reasons(tracked, untracked) else {
3650 return Ok(());
3651 };
3652 Err(uncertain_worktree_change(
3653 cwd,
3654 format!(
3655 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3656 for recovery.",
3657 cwd.display()
3658 ),
3659 ))
3660 }
3661
3662 pub(crate) fn refuse_new_ignored_files(
3666 &self,
3667 cwd: &Path,
3668 baseline: &WorktreeBaseline,
3669 ) -> Result<()> {
3670 self.allow_ignored_files(cwd, baseline).map(drop)
3671 }
3672
3673 pub(crate) fn refuse_failed_edit_leavings(
3675 &self,
3676 cwd: &Path,
3677 baseline: &WorktreeBaseline,
3678 ) -> Result<()> {
3679 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3680 .map(drop)
3681 }
3682
3683 pub(crate) fn refuse_read_only_leavings(
3685 &self,
3686 cwd: &Path,
3687 baseline: &WorktreeBaseline,
3688 ) -> Result<()> {
3689 self.refuse_changed_attributes(cwd, baseline)?;
3690 let after = ignored_untracked_state(cwd).map_err(|e| {
3691 uncertain_worktree_change(
3692 cwd,
3693 format!(
3694 "could not verify untracked files in {} after a read-only call: {}. The \
3695 worktree was kept for recovery.",
3696 cwd.display(),
3697 e.last_line()
3698 ),
3699 )
3700 })?;
3701 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3702 if leavings.condemning.is_empty() {
3703 return Ok(());
3704 }
3705 Err(uncertain_worktree_change(
3706 cwd,
3707 format!(
3708 "the read-only call left untracked file(s) in {} that the project does not \
3709 ignore: {}. The worktree was kept for recovery.",
3710 cwd.display(),
3711 list_paths(&leavings.condemning)
3712 ),
3713 ))
3714 }
3715
3716 fn allow_ignored_files(
3719 &self,
3720 cwd: &Path,
3721 baseline: &WorktreeBaseline,
3722 ) -> Result<AllowedArtifacts> {
3723 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3724 }
3725
3726 fn check_new_ignored_files(
3727 &self,
3728 cwd: &Path,
3729 baseline: &WorktreeBaseline,
3730 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3731 ) -> Result<AllowedArtifacts> {
3732 self.refuse_changed_attributes(cwd, baseline)?;
3733 let after = ignored_untracked_state(cwd).map_err(|e| {
3734 uncertain_worktree_change(
3735 cwd,
3736 format!(
3737 "could not verify untracked files in {} after editing: {}. The worktree was \
3738 kept for recovery.",
3739 cwd.display(),
3740 e.last_line()
3741 ),
3742 )
3743 })?;
3744 let changed = baseline.ignored_untracked.changed_paths(&after);
3745 if changed.is_empty() {
3746 return Ok(AllowedArtifacts::default());
3747 }
3748 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3749 .into_iter()
3750 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3751 if changed.is_empty() {
3752 return Ok(AllowedArtifacts::split(ignored, &after));
3753 }
3754 let listed = list_paths(&changed);
3755 Err(uncertain_worktree_change(
3756 cwd,
3757 format!(
3758 "the editing call created or changed untracked or ignored file(s) in {} that \
3759 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3760 recovery.",
3761 cwd.display()
3762 ),
3763 ))
3764 }
3765
3766 pub(crate) fn refuse_changed_existing_untracked(
3771 &self,
3772 cwd: &Path,
3773 baseline: &WorktreeBaseline,
3774 ) -> Result<()> {
3775 self.check_changed_existing_untracked(cwd, baseline)
3776 .map(drop)
3777 }
3778
3779 fn allow_changed_ignored_artifacts(
3780 &self,
3781 cwd: &Path,
3782 baseline: &WorktreeBaseline,
3783 ) -> Result<AllowedArtifacts> {
3784 self.check_changed_existing_untracked(cwd, baseline)
3785 }
3786
3787 fn check_changed_existing_untracked(
3788 &self,
3789 cwd: &Path,
3790 baseline: &WorktreeBaseline,
3791 ) -> Result<AllowedArtifacts> {
3792 self.refuse_changed_attributes(cwd, baseline)?;
3793 let after = ignored_untracked_state(cwd).map_err(|e| {
3794 uncertain_worktree_change(
3795 cwd,
3796 format!(
3797 "could not verify existing untracked files in {} after editing: {}. The \
3798 worktree was kept for recovery.",
3799 cwd.display(),
3800 e.last_line()
3801 ),
3802 )
3803 })?;
3804 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3805 if changed.is_empty() {
3806 return Ok(AllowedArtifacts::default());
3807 }
3808 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3809 .into_iter()
3810 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3811 if changed.is_empty() {
3812 return Ok(AllowedArtifacts::split(ignored, &after));
3813 }
3814 let listed = list_paths(&changed);
3815 Err(uncertain_worktree_change(
3816 cwd,
3817 format!(
3818 "the editing call changed or deleted existing untracked file(s) in {}: \
3819 {listed}. The worktree was kept for recovery.",
3820 cwd.display()
3821 ),
3822 ))
3823 }
3824
3825 pub(crate) fn refuse_unrepresented_tracked_changes(
3832 &self,
3833 cwd: &Path,
3834 baseline: &WorktreeBaseline,
3835 ) -> Result<()> {
3836 self.refuse_changed_attributes(cwd, baseline)?;
3837 let after = safe_git_state(cwd).map_err(|e| {
3838 uncertain_worktree_change(
3839 cwd,
3840 format!(
3841 "could not verify tracked files in {} after editing: {}. The worktree was \
3842 kept for recovery.",
3843 cwd.display(),
3844 e.last_line()
3845 ),
3846 )
3847 })?;
3848 let mut changed = Vec::new();
3849 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3850 uncertain_worktree_change(
3851 cwd,
3852 format!(
3853 "could not record untracked files before verifying transformed content in {}: \
3854 {}. The worktree was kept for recovery.",
3855 cwd.display(),
3856 e.last_line()
3857 ),
3858 )
3859 })?;
3860 let mut filter_was_run = false;
3861 let mut filter_problem = None;
3862 let mut repositories: BTreeSet<PathBuf> =
3863 baseline.git_state.repositories.keys().cloned().collect();
3864 repositories.extend(after.repositories.keys().cloned());
3865 'repositories: for repository_path in repositories {
3866 let before_repository = baseline.git_state.repositories.get(&repository_path);
3867 let after_repository = after.repositories.get(&repository_path);
3868 if before_repository.is_none() || after_repository.is_none() {
3869 changed.push(repository_path.clone());
3870 continue;
3871 }
3872 if before_repository.map(|repository| &repository.gitlinks)
3873 != after_repository.map(|repository| &repository.gitlinks)
3874 {
3875 changed.push(repository_path.join("<gitlinks>"));
3876 }
3877 let mut paths = BTreeSet::new();
3878 if let Some(repository) = before_repository {
3879 paths.extend(repository.tracked.keys().cloned());
3880 }
3881 if let Some(repository) = after_repository {
3882 paths.extend(repository.tracked.keys().cloned());
3883 }
3884 for path in paths {
3885 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3886 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3887 let worktree_changed =
3888 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3889 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3890 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3891 if !worktree_changed {
3892 continue;
3893 }
3894 if !index_changed {
3895 changed.push(repository_path.join(&path));
3896 continue;
3897 }
3898 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3899 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3900 let Some(current_entry) = current else {
3901 continue;
3902 };
3903 let Some(current_worktree) = current_worktree else {
3904 continue;
3905 };
3906 let content_changed =
3907 before_worktree.map(|file| file.content) != Some(current_worktree.content);
3908 let mode_changed = before_worktree.map(|file| file.mode.as_str())
3909 != Some(current_worktree.mode.as_str());
3910 let repository = cwd.join(&repository_path);
3911 let represented_content = if content_changed {
3912 filter_was_run = true;
3913 let result =
3914 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
3915 self.refuse_changed_attributes(cwd, baseline)?;
3916 match result {
3917 Ok(expected) => expected == current_worktree.content,
3918 Err(error) => {
3919 filter_problem = Some(format!(
3920 "could not verify transformed content for {:?}: {}",
3921 repository_path.join(&path),
3922 error.last_line()
3923 ));
3924 false
3925 }
3926 }
3927 } else {
3928 true
3929 };
3930 let represented_mode =
3931 !mode_changed || current_worktree.mode == current_entry.index_mode;
3932 if !represented_content || !represented_mode {
3933 changed.push(repository_path.join(&path));
3934 }
3935 if filter_problem.is_some() {
3936 break 'repositories;
3937 }
3938 }
3939 }
3940 if filter_was_run {
3941 self.refuse_changed_attributes(cwd, baseline)?;
3942 let verified = safe_git_state(cwd).map_err(|e| {
3943 uncertain_worktree_change(
3944 cwd,
3945 format!(
3946 "could not recheck tracked files after verifying transformed content in \
3947 {}: {}. The worktree was kept for recovery.",
3948 cwd.display(),
3949 e.last_line()
3950 ),
3951 )
3952 })?;
3953 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3954 uncertain_worktree_change(
3955 cwd,
3956 format!(
3957 "could not recheck untracked files after verifying transformed content \
3958 in {}: {}. The worktree was kept for recovery.",
3959 cwd.display(),
3960 e.last_line()
3961 ),
3962 )
3963 })?;
3964 if verified != after
3965 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3966 {
3967 return Err(uncertain_worktree_change(
3968 cwd,
3969 "a content filter changed the worktree while SPAR verified the managed \
3970 commit. The worktree was kept for recovery.",
3971 ));
3972 }
3973 self.refuse_changed_existing_untracked(cwd, baseline)?;
3974 }
3975 if let Some(problem) = filter_problem {
3976 return Err(uncertain_worktree_change(
3977 cwd,
3978 format!("{problem}. The worktree was kept for recovery."),
3979 ));
3980 }
3981 if changed.is_empty() {
3982 return Ok(());
3983 }
3984 let listed = list_paths(&changed);
3985 Err(uncertain_worktree_change(
3986 cwd,
3987 format!(
3988 "the editing call changed tracked working-file bytes, modes, repositories, or \
3989 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3990 recovery."
3991 ),
3992 ))
3993 }
3994
3995 pub(crate) fn refuse_changed_attributes(
3996 &self,
3997 cwd: &Path,
3998 baseline: &WorktreeBaseline,
3999 ) -> Result<()> {
4000 let after = attribute_state(cwd).map_err(|e| {
4001 uncertain_worktree_change(
4002 cwd,
4003 format!(
4004 "could not verify attribute files in {} after editing: {}. The worktree was \
4005 kept for recovery.",
4006 cwd.display(),
4007 e.last_line()
4008 ),
4009 )
4010 })?;
4011 if after == baseline.attributes {
4012 return Ok(());
4013 }
4014 Err(uncertain_worktree_change(
4015 cwd,
4016 format!(
4017 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
4018 refused to run a Git operation that could select a new external filter.",
4019 cwd.display()
4020 ),
4021 ))
4022 }
4023
4024 pub(crate) fn commit_pending_changes(
4029 &self,
4030 cwd: &Path,
4031 baseline: &WorktreeBaseline,
4032 preferred_subject: &str,
4033 fallback_subject: &str,
4034 ) -> Result<bool> {
4035 let mut artifacts = IgnoredArtifacts::default();
4036 self.refuse_changed_attributes(cwd, baseline)?;
4037 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
4038 refuse_unsafe_index_flags(cwd)?;
4039 if !self.has_uncommitted_changes(cwd)? {
4040 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4041 artifacts.report(cwd);
4042 return Ok(false);
4043 }
4044 self.stage_managed_changes(cwd, baseline).map_err(|e| {
4045 e.with_message(format!(
4046 "could not stage changes in {}: {}",
4047 cwd.display(),
4048 e.last_line()
4049 ))
4050 })?;
4051 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4054 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
4055 if !changed_gitlinks.is_empty() {
4056 let listed = list_paths(&changed_gitlinks);
4057 bail!(
4058 "the editing call added or changed a gitlink at {listed}. It was staged but not \
4059 committed because the referenced repository objects might exist only inside \
4060 this worktree. The worktree was kept for recovery."
4061 );
4062 }
4063 let mut subject = self.clean_title(preferred_subject)?;
4064 if subject.trim().is_empty() {
4065 subject = self.clean_title(fallback_subject)?;
4066 }
4067 self.commit_staged_changes(cwd, &subject).map_err(|e| {
4068 e.with_message(format!(
4069 "could not commit changes in {}: {}. The staged files were kept.",
4070 cwd.display(),
4071 e.last_line()
4072 ))
4073 })?;
4074 if has_tracked_or_staged_work(cwd)? {
4075 bail!(
4076 "the commit in {} left additional uncommitted files. They were kept for \
4077 recovery.",
4078 cwd.display()
4079 );
4080 }
4081 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
4082 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4083 artifacts.report(cwd);
4084 Ok(true)
4085 }
4086
4087 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
4088 let after = ignored_untracked_state(cwd)?;
4089 self.git_at_without_automation(cwd, &["add", "-u"])?;
4090 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
4091 if paths.is_empty() {
4092 return Ok(());
4093 }
4094 let mut input = Vec::new();
4095 for path in paths {
4096 input.extend(os_str_bytes(path.as_os_str())?);
4097 input.push(0);
4098 }
4099 let argv = git_without_automation_argv(&[
4100 "--literal-pathspecs",
4101 "add",
4102 "--pathspec-from-file=-",
4103 "--pathspec-file-nul",
4104 ]);
4105 proc::run_with_input_bytes(
4106 &argv,
4107 &self.git_opts(Some(cwd), true).stop_descendants(true),
4108 &input,
4109 )?;
4110 Ok(())
4111 }
4112
4113 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
4116 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
4117 .map(|_| ())
4118 }
4119
4120 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
4127 let range = format!("{}..{refname}", self.base_ref(cwd, base));
4128 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
4129 .trim()
4130 .parse()
4131 .unwrap_or(0)
4132 }
4133
4134 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4138 let range = format!("{}..{refname}", self.base_ref(cwd, base));
4139 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4140 .lines()
4141 .map(str::to_string)
4142 .collect()
4143 }
4144
4145 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
4160 let ancestor = self
4161 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
4162 .is_ok();
4163 if !ancestor {
4164 return None;
4165 }
4166 let range = format!("{earlier}..{later}");
4167 Some(
4168 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4169 .lines()
4170 .map(str::to_string)
4171 .collect(),
4172 )
4173 }
4174
4175 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4178 let range = format!("{}..{refname}", self.base_ref(cwd, base));
4179 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
4180 .lines()
4181 .map(str::trim)
4182 .filter(|line| !line.is_empty())
4183 .map(str::to_string)
4184 .collect()
4185 }
4186
4187 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
4203 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4204 self.git_try_at(
4205 Some(cwd),
4206 &["diff", "--name-only", "--no-renames", "-z", &range],
4207 )
4208 .split('\0')
4209 .filter(|path| !path.is_empty())
4210 .map(str::to_string)
4211 .collect()
4212 }
4213
4214 pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
4229 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4230 let argv = git_without_maintenance_argv(&[
4231 "-c",
4232 "core.quotePath=false",
4233 "diff",
4234 "--no-color",
4235 "--no-ext-diff",
4236 "--no-textconv",
4237 "--unified=3",
4238 "--inter-hunk-context=0",
4239 "--diff-algorithm=myers",
4240 "--indent-heuristic",
4241 "--submodule=short",
4242 "--find-renames",
4243 "--src-prefix=a/",
4244 "--dst-prefix=b/",
4245 &range,
4246 ]);
4247 let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
4248 new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
4249 }
4250
4251 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
4254 self.git_try_at(
4255 Some(cwd),
4256 &[
4257 "diff",
4258 "--name-only",
4259 "--no-renames",
4260 "-z",
4261 &format!("{from}..{to}"),
4262 ],
4263 )
4264 .split('\0')
4265 .filter(|path| !path.is_empty())
4266 .map(str::to_string)
4267 .collect()
4268 }
4269
4270 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
4273 let base_ref = self.base_ref(cwd, base);
4274 let out = self
4275 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
4276 .map_err(|e| {
4277 spar_err!(
4278 "could not find where {refname} and {base_ref} diverged. {}",
4279 e.last_line()
4280 )
4281 })?;
4282 let sha = out.trim().to_string();
4283 if sha.is_empty() {
4284 bail!("{refname} and {base_ref} share no history");
4285 }
4286 Ok(sha)
4287 }
4288
4289 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
4290 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4291 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
4292 full.trim().to_string()
4293 }
4294
4295 pub fn rewrite_commits_if_needed(
4308 &self,
4309 cwd: &Path,
4310 base: &str,
4311 floor: Option<&str>,
4312 ) -> Result<()> {
4313 let base_ref = self.base_ref(cwd, base);
4314 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
4315 self.report_commits_left_alone(cwd, &base_ref, floor);
4316
4317 let range = format!("{floor}..HEAD");
4318 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
4319
4320 let offenders = raw
4321 .split('\x1e')
4322 .filter_map(|entry| entry.split_once('\0'))
4323 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4324 .count();
4325 if offenders == 0 {
4326 return Ok(());
4327 }
4328 logdim!("{offenders} commit message(s) violated style rules, rewriting");
4329
4330 let exe = self_binary()?;
4331 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
4332
4333 let argv: Vec<String> = [
4334 "git",
4335 "filter-branch",
4336 "-f",
4337 "--msg-filter",
4338 &filter,
4339 &range,
4340 ]
4341 .iter()
4342 .map(|s| s.to_string())
4343 .collect();
4344 let opts = ExecOpts::new()
4345 .cwd(cwd)
4346 .check(false)
4347 .timeout_secs(600)
4348 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4349 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4350 .env(
4351 "SPAR_BAN_AI_ATTRIBUTION",
4352 bool_env(self.style.ban_ai_attribution),
4353 );
4354 let _ = proc::run(&argv, &opts);
4355
4356 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4357 if !style::violations(&after, &self.style).is_empty() {
4358 bail!(
4359 "commit messages still violate style rules after a rewrite in {}.",
4360 cwd.display()
4361 );
4362 }
4363 Ok(())
4364 }
4365
4366 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4371 if floor == base_ref {
4372 return;
4373 }
4374 let raw = self.git_try_at(
4375 Some(cwd),
4376 &[
4377 "log",
4378 &format!("{base_ref}..{floor}"),
4379 "--format=%H%x00%B%x1e",
4380 ],
4381 );
4382 let left: Vec<&str> = raw
4383 .split('\x1e')
4384 .filter_map(|entry| entry.split_once('\0'))
4385 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4386 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4387 .collect();
4388 if left.is_empty() {
4389 return;
4390 }
4391 logdim!(
4392 "{} commit message(s) already on the branch break the style rules and were left \
4393 alone: {}. spar does not rewrite commits it did not write.",
4394 left.len(),
4395 left.join(", ")
4396 );
4397 }
4398
4399 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4414 self.push_to(cwd, &PushTarget::Origin, branch, expected)
4415 }
4416
4417 pub fn push_to(
4419 &self,
4420 cwd: &Path,
4421 target: &PushTarget,
4422 branch: &str,
4423 expected: Option<&str>,
4424 ) -> Result<()> {
4425 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4426 if let Some(head) = expected {
4427 self.refuse_push_that_drops(cwd, head)?;
4428 }
4429 let pushed = self
4430 .git_push(cwd, target, branch, expected)
4431 .map_err(|e| push_error(target, branch, &e));
4432 self.record_write(pushed)
4433 }
4434
4435 pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4447 let origin = PushTarget::Origin;
4448 let refused = match self.git_push(cwd, &origin, branch, None) {
4449 Ok(()) => return self.record_write(Ok(origin)),
4450 Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4451 Err(e) => {
4452 let err = push_error(&origin, branch, &e);
4453 let err = if push_denial(&e.to_string()).is_some() {
4454 spar_err!(
4455 "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4456 instead."
4457 )
4458 } else {
4459 err
4460 };
4461 return self.record_write(Err(err));
4462 }
4463 };
4464 self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4465 }
4466
4467 fn publish_on_fork(
4468 &self,
4469 cwd: &Path,
4470 branch: &str,
4471 base: &str,
4472 refused: &crate::error::SparError,
4473 ) -> Result<PushTarget> {
4474 let fork = self.fork().map_err(|e| {
4475 spar_err!(
4476 "{} spar could not publish it from a fork instead: {}",
4477 push_error(&PushTarget::Origin, branch, refused),
4478 e.last_line()
4479 )
4480 })?;
4481 let lease = self.fork_branch_lease(&fork, branch, base)?;
4482 let refused = refused.to_string();
4483 log!(
4484 "{} Publishing {branch} from the fork {} instead.",
4485 push_denial(&refused).unwrap_or("origin refused the push."),
4486 fork.full_name
4487 );
4488 let target = PushTarget::Fork(fork);
4489 self.git_push(cwd, &target, branch, lease.as_deref())
4492 .map_err(|e| push_error(&target, branch, &e))?;
4493 Ok(target)
4494 }
4495
4496 fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4504 let live_ref = format!("refs/heads/{branch}");
4505 let unreadable = |e: crate::error::SparError| {
4506 spar_err!(
4507 "could not check whether the fork {} already has {branch}. {}",
4508 fork.full_name,
4509 e.last_line()
4510 )
4511 };
4512 let listed = self
4513 .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4514 .map_err(unreadable)?;
4515 let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4516 return Ok(None);
4517 };
4518 let local = format!("refs/spar/fork/{branch}");
4519 self.git(&[
4520 "fetch",
4521 "--no-tags",
4522 &fork.url,
4523 &format!("+{live_ref}:{local}"),
4524 ])
4525 .map_err(unreadable)?;
4526 let ahead = self.commit_count_checked(&self.root, &local, base);
4527 let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4528 self.git_try(&["update-ref", "-d", &local]);
4529 let ahead = ahead?;
4530 if !held {
4531 bail!(
4532 "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4533 and no pull request accounts for them. Publishing over it would lose that work.\n\
4534 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4535 run spar again.",
4536 fork.full_name,
4537 fork.url
4538 );
4539 }
4540 Ok(Some(tip))
4541 }
4542
4543 fn git_push(
4544 &self,
4545 cwd: &Path,
4546 target: &PushTarget,
4547 branch: &str,
4548 expected: Option<&str>,
4549 ) -> Result<()> {
4550 let refspec = format!("HEAD:refs/heads/{branch}");
4551 let lease = match (expected, target) {
4552 (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4553 (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4557 (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4558 };
4559 self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4560 .map(|_| ())
4561 }
4562
4563 fn fork(&self) -> Result<Fork> {
4570 match self.existing_fork() {
4571 Ok(Some(fork)) => return Ok(fork),
4572 Ok(None) => {}
4573 Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4574 }
4575 let answer = self
4576 .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4577 .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4578 let fork = fork_from_api(&answer, &self.origin_push_url())?;
4579 self.wait_for_fork(&fork)?;
4580 Ok(self.fork.get_or_init(|| fork).clone())
4581 }
4582
4583 fn existing_fork(&self) -> Result<Option<Fork>> {
4586 if let Some(fork) = self.fork.get() {
4587 return Ok(Some(fork.clone()));
4588 }
4589 let answer = self
4590 .gh(&[
4591 "api",
4592 "graphql",
4593 "-F",
4594 "owner={owner}",
4595 "-F",
4596 "name={repo}",
4597 "-f",
4598 "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4599 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4600 login } sshUrl url } } } }",
4601 "--jq",
4602 ".data.repository.forks.nodes",
4603 ])
4604 .map_err(|e| {
4605 spar_err!(
4606 "could not look for a fork of this repository. {}",
4607 e.last_line()
4608 )
4609 })?;
4610 let found = fork_from_listing(&answer, &self.origin_push_url())?;
4611 Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4612 }
4613
4614 fn origin_refuses_push(&self) -> bool {
4618 self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4619 .trim()
4620 == "false"
4621 }
4622
4623 fn origin_push_url(&self) -> String {
4624 self.git_try(&["remote", "get-url", "--push", "origin"])
4625 }
4626
4627 pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4632 if !pr.is_cross_repository {
4633 return Some(PushTarget::Origin);
4634 }
4635 let viewer = self.viewer_login().ok()?;
4636 own_fork_target(pr, viewer, &self.origin_push_url())
4637 }
4638
4639 fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4640 const TRIES: u32 = 30;
4641 let mut tries = 1;
4642 loop {
4643 match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4644 Ok(_) => return Ok(()),
4645 Err(e) if tries >= TRIES => bail!(
4646 "the fork {} could not be reached after {} seconds. {}",
4647 fork.full_name,
4648 TRIES * 2,
4649 e.last_line()
4650 ),
4651 Err(_) => {
4652 tries += 1;
4653 std::thread::sleep(std::time::Duration::from_secs(2));
4654 }
4655 }
4656 }
4657 }
4658
4659 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4667 let head = self.head_oid_checked(cwd)?;
4668 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4669 return Ok(());
4670 }
4671 bail!(
4672 "refusing to push {head} because it does not contain the reviewed head {expected}. \
4673 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4674 the intended commits on top of it before resuming.",
4675 cwd.display()
4676 )
4677 }
4678
4679 pub fn push_split_branch(
4688 &self,
4689 cwd: &Path,
4690 branch: &str,
4691 ) -> std::result::Result<(), SplitPushError> {
4692 let remote_ref = format!("refs/heads/{branch}");
4693 let lease = format!("--force-with-lease={remote_ref}:");
4694 let refspec = format!("HEAD:{remote_ref}");
4695 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4696 Ok(_) => Ok(()),
4697 Err(push_error) => {
4698 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4699 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4700 reconcile_failed_split_push(branch, push_error, local, remote)
4701 }
4702 };
4703 self.record_write(result)
4704 }
4705
4706 pub fn gh(&self, args: &[&str]) -> Result<String> {
4709 self.gh_at(None, args)
4710 }
4711
4712 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4713 let mut argv = vec!["gh".to_string()];
4714 argv.extend(args.iter().map(|s| s.to_string()));
4715 proc::run(
4716 &argv,
4717 &ExecOpts::new()
4718 .cwd(cwd.unwrap_or(&self.root))
4719 .timeout_secs(300),
4720 )
4721 }
4722
4723 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4729 let mut argv = vec!["gh".to_string()];
4730 argv.extend(args.iter().map(|s| s.to_string()));
4731 proc::run(
4732 &argv,
4733 &ExecOpts::new()
4734 .cwd(&self.root)
4735 .timeout_secs(300)
4736 .stdin(stdin),
4737 )
4738 }
4739
4740 pub fn gh_try(&self, args: &[&str]) -> String {
4741 let mut argv = vec!["gh".to_string()];
4742 argv.extend(args.iter().map(|s| s.to_string()));
4743 proc::run(
4744 &argv,
4745 &ExecOpts::new()
4746 .cwd(&self.root)
4747 .check(false)
4748 .timeout_secs(300),
4749 )
4750 .unwrap_or_default()
4751 }
4752
4753 pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4758 let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4759 for (name, value) in headers {
4760 argv.push("-H".to_string());
4761 argv.push(format!("{name}: {value}"));
4762 }
4763 argv.push(endpoint.to_string());
4764 let out = proc::exec(
4765 &argv,
4766 &ExecOpts::new()
4767 .cwd(&self.root)
4768 .check(false)
4769 .timeout_secs(300),
4770 )?;
4771 parse_api_response(&out.stdout)
4772 .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4773 }
4774
4775 pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4777 let text = self.gh(&[
4778 "pr",
4779 "list",
4780 "--state",
4781 "open",
4782 "--limit",
4783 "500",
4784 "--json",
4785 PR_SWEEP_FIELDS,
4786 ])?;
4787 serde_json::from_str(&text)
4788 .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4789 }
4790
4791 pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4793 let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4794 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4795 }
4796
4797 pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4802 let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4804 if slug.is_empty() || login.is_empty() {
4805 return None;
4806 }
4807 let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4808 let resp = self.gh_api_raw(&path, &[]).ok()?;
4809 match resp.status {
4810 200 => {
4811 let body: Value = serde_json::from_str(&resp.body).ok()?;
4812 Some(body.get("state")?.as_str()? == "active")
4813 }
4814 404 => Some(false),
4815 _ => None,
4816 }
4817 }
4818
4819 pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4826 let text = self
4827 .gh(&[
4828 "api",
4829 "graphql",
4830 "-F",
4831 "owner={owner}",
4832 "-F",
4833 "repo={repo}",
4834 "-F",
4835 &format!("number={number}"),
4836 "-f",
4837 &format!("query={REQUESTS_QUERY}"),
4838 ])
4839 .ok()?;
4840 parse_review_requests(&text)
4841 }
4842
4843 pub fn sweep_assigned_issues(&self, login: &str) -> Result<Vec<crate::model::AssignedIssue>> {
4848 let text = self.gh(&[
4849 "api",
4850 "graphql",
4851 "-F",
4852 "owner={owner}",
4853 "-F",
4854 "repo={repo}",
4855 "-f",
4856 &format!("login={login}"),
4857 "-f",
4858 &format!("query={}", assigned_issues_query()),
4859 ])?;
4860 parse_assigned_issues(&text)
4861 .ok_or_else(|| spar_err!("unexpected shape for the issues assigned to {login}"))
4862 }
4863
4864 pub fn issue_assignments(&self, number: i64) -> Result<crate::model::AssignedIssue> {
4867 let text = self.gh(&[
4868 "api",
4869 "graphql",
4870 "-F",
4871 "owner={owner}",
4872 "-F",
4873 "repo={repo}",
4874 "-F",
4875 &format!("number={number}"),
4876 "-f",
4877 &format!("query={}", issue_assignments_query()),
4878 ])?;
4879 parse_issue_assignments(&text)
4880 .ok_or_else(|| spar_err!("unexpected shape for the assignments on #{number}"))
4881 }
4882
4883 pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4885 if from.is_empty() || to.is_empty() {
4886 return None;
4887 }
4888 let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4889 self.gh_try(&["api", &path, "--jq", ".total_commits"])
4890 .trim()
4891 .parse()
4892 .ok()
4893 }
4894
4895 pub fn repo_name(&self) -> Option<String> {
4897 let name = self.gh_try(&[
4898 "repo",
4899 "view",
4900 "--json",
4901 "nameWithOwner",
4902 "--jq",
4903 ".nameWithOwner",
4904 ]);
4905 let name = name.trim();
4906 (!name.is_empty()).then(|| name.to_string())
4907 }
4908
4909 pub fn listen_state_path(&self) -> PathBuf {
4910 self.root.join(STATE_DIR).join("state").join("listen.json")
4911 }
4912
4913 pub fn listen_lock_path(&self) -> PathBuf {
4914 self.root.join(STATE_DIR).join("state").join("listen.lock")
4915 }
4916
4917 pub fn viewer_login(&self) -> Result<&str> {
4928 if let Some(login) = self.viewer.get() {
4929 return Ok(login);
4930 }
4931 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4932 let login = if !rest.trim().is_empty() {
4933 rest.trim().to_string()
4934 } else {
4935 self.gh(&[
4938 "api",
4939 "graphql",
4940 "-f",
4941 "query={ viewer { login } }",
4942 "--jq",
4943 ".data.viewer.login",
4944 ])
4945 .map_err(|e| {
4946 spar_err!(
4947 "could not find out who `gh` is authenticated as, so spar cannot tell its \
4948 own comments from anybody else's. {}\nRun `gh auth status`.",
4949 e.last_line()
4950 )
4951 })?
4952 .trim()
4953 .to_string()
4954 };
4955 if login.is_empty() {
4956 bail!("`gh` reported an empty login. Run `gh auth status`.");
4957 }
4958 Ok(self.viewer.get_or_init(|| login))
4959 }
4960
4961 pub fn read_issue(&self, number: i64) -> Result<Issue> {
4967 let text = self
4968 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4969 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4970 serde_json::from_str(&text)
4971 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4972 }
4973
4974 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4975 let mut issues = Vec::new();
4976 for number in numbers {
4977 let text = self
4978 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4979 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4980 let issue: Issue = serde_json::from_str(&text)
4981 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4982 if issue.is_closed() {
4983 crate::log!("issue #{number} is closed, skipping");
4984 continue;
4985 }
4986 issues.push(issue);
4987 }
4988 if issues.is_empty() {
4989 bail!("no open issues to work on");
4990 }
4991 Ok(issues)
4992 }
4993
4994 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5000 #[derive(Deserialize)]
5001 struct Row {
5002 number: i64,
5003 }
5004 let text = self.gh(&[
5005 kind,
5006 "list",
5007 "--state",
5008 "open",
5009 "--limit",
5010 &FETCH_CEILING.to_string(),
5011 "--json",
5012 "number",
5013 ])?;
5014 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5015 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
5016 numbers.sort_unstable();
5017
5018 let noun = if kind == "issue" { "issues" } else { "PRs" };
5019 let found = numbers.len();
5020 if min_number > 0 {
5021 numbers.retain(|n| *n >= min_number);
5022 let skipped = found - numbers.len();
5023 if skipped > 0 {
5024 crate::log!("{skipped} open {noun} below #{min_number} skipped");
5025 }
5026 }
5027 if found >= FETCH_CEILING {
5028 crate::log!(
5029 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
5030 considered."
5031 );
5032 }
5033 if numbers.len() > limit {
5034 crate::log!(
5035 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
5036 explicitly.",
5037 numbers.len()
5038 );
5039 numbers.truncate(limit);
5040 }
5041 Ok(numbers)
5042 }
5043
5044 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5046 self.open_numbers("issue", limit, min_number)
5047 }
5048
5049 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5050 self.open_numbers("pr", limit, min_number)
5051 }
5052
5053 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
5054 self.branch_prs(branch, "open").into_iter().next()
5055 }
5056
5057 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
5060 let text = self.gh(&[
5061 "pr",
5062 "list",
5063 "--head",
5064 branch,
5065 "--base",
5066 base,
5067 "--state",
5068 "open",
5069 "--json",
5070 "number,url,title,baseRefName",
5071 ])?;
5072 pr_for_base(&text, branch, base)
5073 }
5074
5075 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
5079 self.branch_prs(branch, "all")
5080 }
5081
5082 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
5083 let text = self.gh_try(&[
5084 "pr",
5085 "list",
5086 "--head",
5087 branch,
5088 "--state",
5089 state,
5090 "--json",
5091 "number,url,title",
5092 ]);
5093 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
5094 }
5095
5096 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
5102 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
5103 let text = self
5104 .gh(&[
5105 "api",
5106 &path,
5107 "--jq",
5108 "if .pull_request then \"pr\" else \"issue\" end",
5109 ])
5110 .map_err(|e| {
5111 spar_err!(
5112 "no issue or pull request #{number} in this repository. {}",
5113 e.last_line()
5114 )
5115 })?;
5116 match text.trim() {
5117 "pr" => Ok(ItemKind::Pr),
5118 "issue" => Ok(ItemKind::Issue),
5119 other => Err(spar_err!(
5120 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
5121 )),
5122 }
5123 }
5124
5125 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
5136 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
5137 if let Some(pr) = mine {
5138 return Ok(Some(pr));
5139 }
5140 let listed = match self.gh(&[
5141 "pr",
5142 "list",
5143 "--state",
5144 "open",
5145 "--limit",
5146 &FETCH_CEILING.to_string(),
5147 "--json",
5148 "number,url,title,baseRefName,closingIssuesReferences",
5149 ]) {
5150 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
5151 spar_err!("could not read the pull request list while looking for #{issue}")
5152 })?,
5153 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
5154 };
5155 if listed.len() >= FETCH_CEILING {
5156 logwarn!(
5157 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
5158 );
5159 }
5160 let default = self.default_branch(base);
5161 if !base.eq_ignore_ascii_case(&default) {
5162 logwarn!(
5166 "#{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."
5167 );
5168 }
5169 Ok(pick_linked_pr(&listed, issue))
5170 }
5171
5172 fn pr_for_branch_failing_closed(
5174 &self,
5175 branch: &str,
5176 base: &str,
5177 issue: i64,
5178 ) -> Result<Option<PrRef>> {
5179 match self.try_pr_for_branch(branch, base) {
5180 Ok(found) => Ok(found),
5181 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
5182 }
5183 }
5184
5185 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
5194 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
5195 return Err(spar_err!(
5196 "could not check whether a pull request already exists for #{issue}: {}. \
5197 Refusing to implement over the top of work that may already be open.",
5198 e.last_line()
5199 ));
5200 }
5201 logwarn!(
5202 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
5203 whether a pull request already exists for it.",
5204 e.last_line()
5205 );
5206 Ok(())
5207 }
5208
5209 pub fn pr_view(&self, number: i64) -> Result<PrView> {
5210 let text = self.gh(&[
5211 "pr",
5212 "view",
5213 &number.to_string(),
5214 "--json",
5215 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
5216 isCrossRepository,headRepositoryOwner,headRepository",
5217 ])?;
5218 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
5219 }
5220
5221 fn try_pr_state(&self, number: i64) -> Result<String> {
5222 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
5223 serde_json::from_str::<Value>(text.trim())
5224 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
5225 .get("state")
5226 .and_then(Value::as_str)
5227 .map(str::to_string)
5228 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
5229 }
5230
5231 pub fn pr_state(&self, number: i64) -> String {
5232 self.try_pr_state(number).unwrap_or_default()
5233 }
5234
5235 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
5237 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
5238 let oid = serde_json::from_str::<Value>(&text)
5239 .ok()
5240 .and_then(|value| {
5241 value
5242 .get("headRefOid")
5243 .and_then(Value::as_str)
5244 .map(str::trim)
5245 .filter(|oid| !oid.is_empty())
5246 .map(str::to_string)
5247 })
5248 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
5249 Ok(oid)
5250 }
5251
5252 pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
5256 let pull = self.pr_head_oid(pr.number)?;
5257 let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
5258 None
5259 } else {
5260 let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
5261 let listed = self
5262 .git(&["ls-remote", "--heads", "origin", &remote_ref])
5263 .map_err(|e| {
5264 spar_err!(
5265 "could not read where {} is on origin: {}",
5266 pr.head_ref_name.trim(),
5267 e.last_line()
5268 )
5269 })?;
5270 remote_head_oid(&listed, &remote_ref)?
5271 };
5272 Ok(LiveHead { pull, branch })
5273 }
5274
5275 pub fn create_pr(
5280 &self,
5281 cwd: &Path,
5282 branch: &str,
5283 target: &PushTarget,
5284 base: &str,
5285 title: &str,
5286 body: &str,
5287 ) -> Result<PrRef> {
5288 let title = self.record_failed_write(self.clean_title(title))?;
5289 let body = self.record_failed_write(self.clean(body))?;
5290 let head = target.head(branch);
5291 let mut argv = vec![
5292 "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
5293 ];
5294 if self.drafts != Drafts::Never {
5295 argv.push("--draft");
5296 }
5297 let created = self.gh_at(Some(cwd), &argv);
5298 let found = self.try_pr_for_branch(branch, base);
5299 self.record_write(reconcile_pr_creation(branch, created, found))
5300 }
5301
5302 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
5303 let body = signed(&self.record_failed_write(self.clean(body))?);
5304 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5305 if has_exact_comment(&comments, &body) {
5306 return Ok(());
5307 }
5308 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
5309 let result = match posted {
5310 Ok(_) => Ok(()),
5311 Err(post_error) => {
5312 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5313 }
5314 };
5315 self.record_write(result)
5316 }
5317
5318 fn pr_is_own(&self, number: i64) -> Result<bool> {
5322 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
5323 let author = serde_json::from_str::<Value>(&text)
5324 .ok()
5325 .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
5326 .unwrap_or_default();
5327 let viewer = self.viewer_login()?.to_string();
5328 Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
5329 }
5330
5331 pub fn submit_review(
5356 &self,
5357 number: i64,
5358 verdict: Option<crate::model::ReviewVerdict>,
5359 body: &str,
5360 full: &str,
5361 commit: &str,
5362 comments: &[InlineComment],
5363 ) -> Result<()> {
5364 let own = match verdict {
5365 Some(_) => self.record_failed_write(self.pr_is_own(number))?,
5366 None => false,
5367 };
5368 let prepare = |body: &str| {
5369 let text = match (verdict, own) {
5370 (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
5371 _ => body.to_string(),
5372 };
5373 self.record_failed_write(self.clean(&text))
5374 .map(|b| signed(&b))
5375 };
5376 let body = prepare(body)?;
5377 let full = prepare(full)?;
5378 let mut inline = Vec::with_capacity(comments.len());
5379 for c in comments {
5380 inline.push(InlineComment {
5381 body: signed(&self.record_failed_write(self.clean(&c.body))?),
5382 ..c.clone()
5383 });
5384 }
5385 let either = [body.as_str(), full.as_str()];
5386 let viewer = self.record_failed_write(self.viewer_login())?.to_string();
5387 let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
5388 if has_viewer_review(&reviews, &viewer, &either, commit) {
5389 return Ok(());
5390 }
5391 let event = match (verdict, own) {
5392 (Some(v), false) => v.api_event(),
5393 _ => "COMMENT",
5394 };
5395 let result = if inline.is_empty() {
5396 self.post_review(number, &full, &viewer, commit, event)
5397 } else {
5398 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5399 let payload = review_payload(commit, event, &body, &inline).to_string();
5400 match self.gh_stdin(
5401 &["api", "-X", "POST", &path, "--input", "-", "--silent"],
5402 &payload,
5403 ) {
5404 Ok(_) => Ok(()),
5405 Err(post_error) if !refused(&post_error) => reconcile_review_post(
5406 number,
5407 &[&body],
5408 &viewer,
5409 commit,
5410 post_error,
5411 self.try_pr_reviews(number),
5412 ),
5413 Err(post_error) => match self.try_pr_reviews(number) {
5414 Ok(reviews) if !has_viewer_review(&reviews, &viewer, &either, commit) => {
5415 logwarn!(
5416 "the review of #{number} with its line comments did not go up ({}), \
5417 so it is going up with every finding in its body only",
5418 post_error.last_line()
5419 );
5420 self.post_review(number, &full, &viewer, commit, event)
5421 }
5422 reviews => {
5423 reconcile_review_post(number, &either, &viewer, commit, post_error, reviews)
5424 }
5425 },
5426 }
5427 };
5428 self.record_write(result)
5429 }
5430
5431 fn post_review(
5433 &self,
5434 number: i64,
5435 body: &str,
5436 viewer: &str,
5437 commit: &str,
5438 event: &str,
5439 ) -> Result<()> {
5440 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5441 let posted = self.gh(&[
5442 "api",
5443 "-X",
5444 "POST",
5445 &path,
5446 "-f",
5447 &format!("commit_id={commit}"),
5448 "-f",
5449 &format!("event={event}"),
5450 "-f",
5451 &format!("body={body}"),
5452 "--silent",
5453 ]);
5454 match posted {
5455 Ok(_) => Ok(()),
5456 Err(post_error) => reconcile_review_post(
5457 number,
5458 &[body],
5459 viewer,
5460 commit,
5461 post_error,
5462 self.try_pr_reviews(number),
5463 ),
5464 }
5465 }
5466
5467 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5468 let body = signed(&self.record_failed_write(self.clean(body))?);
5469 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5470 if has_exact_comment(&comments, &body) {
5471 return Ok(());
5472 }
5473 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5474 let result = match posted {
5475 Ok(_) => Ok(()),
5476 Err(post_error) => {
5477 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5478 }
5479 };
5480 self.record_write(result)
5481 }
5482
5483 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5488 self.comment_issue(number, body)?;
5489 let n = number.to_string();
5490 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5491 Ok(_) => Ok(()),
5492 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5494 spar_err!(
5495 "commented on #{number} but could not close it: {}",
5496 e.last_line()
5497 )
5498 }),
5499 };
5500 self.record_write(closed)
5501 }
5502
5503 pub fn edit_issue_body(
5518 &self,
5519 number: i64,
5520 expected: &str,
5521 body: &str,
5522 inserted: &str,
5523 ) -> Result<()> {
5524 let cleaned = self.record_failed_write(self.clean(inserted))?;
5525 if cleaned.trim() != inserted.trim() {
5526 return self.record_failed_write(Err(spar_err!(
5527 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5528 )));
5529 }
5530 let current = self.record_failed_write(self.issue_body(number))?;
5531 if current != expected {
5532 return self.record_failed_write(Err(spar_err!(
5533 "the body of #{number} changed since it was read, so it was left alone rather \
5534 than written over."
5535 )));
5536 }
5537 let edited = self.gh_stdin(
5538 &["issue", "edit", &number.to_string(), "--body-file", "-"],
5539 body,
5540 );
5541 let result = match edited {
5542 Ok(_) => Ok(()),
5543 Err(edit_error) => {
5544 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5545 }
5546 };
5547 self.record_write(result)
5548 }
5549
5550 pub fn issue_body(&self, number: i64) -> Result<String> {
5552 #[derive(Deserialize)]
5553 struct Row {
5554 #[serde(default)]
5555 body: Option<String>,
5556 }
5557 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5558 let row: Row = serde_json::from_str(text.trim())
5559 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5560 Ok(row.body.unwrap_or_default())
5561 }
5562
5563 pub fn open_issue_rows(&self) -> Vec<Issue> {
5568 let text = self.gh_try(&[
5569 "issue",
5570 "list",
5571 "--state",
5572 "open",
5573 "--limit",
5574 &FETCH_CEILING.to_string(),
5575 "--json",
5576 "number,title,body,labels,state,stateReason,url",
5577 ]);
5578 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5579 }
5580
5581 pub fn open_pr_rows(&self) -> Vec<PrRow> {
5583 let text = self.gh_try(&[
5584 "pr",
5585 "list",
5586 "--state",
5587 "open",
5588 "--limit",
5589 &FETCH_CEILING.to_string(),
5590 "--json",
5591 "number,title,changedFiles,additions,deletions",
5592 ]);
5593 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5594 }
5595
5596 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5597 self.create_issue_apart_from(title, body, None)
5598 }
5599
5600 pub fn create_issue_apart_from(
5601 &self,
5602 title: &str,
5603 body: &str,
5604 apart_from: Option<i64>,
5605 ) -> Result<String> {
5606 let title = self.record_failed_write(self.clean_title(title))?;
5607 let body = self.record_failed_write(self.clean_issue_body(body))?;
5608 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5609 let result = match created {
5610 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5611 created => {
5612 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5613 reconcile_issue_creation(&title, created, found)
5614 }
5615 };
5616 self.record_write(result)
5617 }
5618}
5619
5620#[derive(Debug, Clone)]
5622pub struct ExistingIssue {
5623 pub number: i64,
5624 pub url: String,
5625 pub title: String,
5626 pub body: String,
5627 pub open: bool,
5628}
5629
5630impl Repo {
5631 pub(crate) fn try_exact_issue_apart_from(
5632 &self,
5633 title: &str,
5634 body: &str,
5635 apart_from: Option<i64>,
5636 ) -> Result<Option<ExistingIssue>> {
5637 #[derive(Deserialize)]
5638 #[serde(rename_all = "camelCase")]
5639 struct Row {
5640 number: i64,
5641 #[serde(default)]
5642 title: String,
5643 #[serde(default)]
5644 url: String,
5645 #[serde(default)]
5646 body: Option<String>,
5647 #[serde(default)]
5648 state: String,
5649 }
5650
5651 let text = self.gh(&[
5652 "issue",
5653 "list",
5654 "--state",
5655 "all",
5656 "--limit",
5657 "100",
5658 "--json",
5659 "number,title,url,body,state,stateReason",
5660 ])?;
5661 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5662 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5663 Ok(rows
5664 .into_iter()
5665 .filter(|row| Some(row.number) != apart_from)
5666 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5667 .map(|row| ExistingIssue {
5668 number: row.number,
5669 url: row.url,
5670 title: row.title,
5671 body: row.body.unwrap_or_default(),
5672 open: row.state.eq_ignore_ascii_case("open"),
5673 }))
5674 }
5675
5676 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5684 self.find_similar_issue_apart_from(title, body, None)
5685 }
5686
5687 pub fn find_similar_issue_apart_from(
5695 &self,
5696 title: &str,
5697 body: &str,
5698 apart_from: Option<i64>,
5699 ) -> Option<ExistingIssue> {
5700 self.try_similar_issue(title, body, apart_from, true)
5701 .ok()
5702 .flatten()
5703 }
5704
5705 pub fn try_find_similar_issue_apart_from(
5707 &self,
5708 title: &str,
5709 body: &str,
5710 apart_from: Option<i64>,
5711 ) -> Result<Option<ExistingIssue>> {
5712 self.try_similar_issue(title, body, apart_from, false)
5713 }
5714
5715 fn try_similar_issue(
5717 &self,
5718 title: &str,
5719 body: &str,
5720 apart_from: Option<i64>,
5721 include_closed: bool,
5722 ) -> Result<Option<ExistingIssue>> {
5723 #[derive(Deserialize)]
5724 #[serde(rename_all = "camelCase")]
5725 struct Row {
5726 number: i64,
5727 #[serde(default)]
5728 title: String,
5729 #[serde(default)]
5730 url: String,
5731 #[serde(default)]
5732 body: String,
5733 #[serde(default)]
5734 state: String,
5735 }
5736 if title.trim().is_empty() {
5737 return Ok(None);
5738 }
5739 let query: String = title
5742 .chars()
5743 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5744 .take(120)
5745 .collect();
5746 let state = if include_closed { "all" } else { "open" };
5755 let text = self.gh(&[
5756 "issue",
5757 "list",
5758 "--state",
5759 state,
5760 "--limit",
5761 "100",
5762 "--search",
5763 query.trim(),
5764 "--json",
5765 "number,title,url,body,state,stateReason",
5766 ])?;
5767 let rows: Vec<Row> = serde_json::from_str(text.trim())
5768 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5769
5770 Ok(rows
5771 .into_iter()
5772 .filter(|row| Some(row.number) != apart_from)
5773 .find(|row| {
5774 row.title.trim().eq_ignore_ascii_case(title.trim())
5775 || textsim::covers(title, body, &row.title, &row.body)
5776 })
5777 .map(|row| ExistingIssue {
5778 number: row.number,
5779 url: row.url,
5780 title: row.title,
5781 open: row.state.eq_ignore_ascii_case("open"),
5782 body: row.body,
5783 }))
5784 }
5785
5786 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5788 #[derive(Deserialize)]
5789 struct Row {
5790 title: String,
5791 url: String,
5792 }
5793 let needle = title.trim().to_lowercase();
5794 if needle.is_empty() {
5795 return None;
5796 }
5797 let query: String = title
5799 .chars()
5800 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5801 .take(120)
5802 .collect();
5803 let text = self.gh_try(&[
5804 "issue",
5805 "list",
5806 "--state",
5807 "all",
5808 "--limit",
5809 "100",
5810 "--search",
5811 query.trim(),
5812 "--json",
5813 "number,title,url",
5814 ]);
5815 serde_json::from_str::<Vec<Row>>(text.trim())
5816 .ok()?
5817 .into_iter()
5818 .find(|row| row.title.trim().to_lowercase() == needle)
5819 .map(|row| row.url)
5820 }
5821
5822 pub fn mark_ready(&self, number: i64) -> bool {
5830 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5831 Ok(_) => true,
5832 Err(e) => {
5833 logdim!(
5834 "PR #{number} is approved but could not be taken out of draft: {}",
5835 e.last_line()
5836 );
5837 false
5838 }
5839 }
5840 }
5841
5842 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5844 let listed = self
5845 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5846 .ok()?;
5847 let wanted = format!("branch refs/heads/{branch}");
5848 let mut current: Option<PathBuf> = None;
5849 for line in listed.lines() {
5850 if let Some(path) = line.strip_prefix("worktree ") {
5851 current = Some(PathBuf::from(path));
5852 } else if line == wanted {
5853 return current;
5854 }
5855 }
5856 None
5857 }
5858
5859 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5875 let branch = self.pr_view(number).ok()?.head_ref_name;
5876 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5877 return None;
5878 }
5879 let mut released = None;
5880 if let Some(path) = self.worktree_holding(&branch) {
5881 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5882 return None;
5883 }
5884 if !matches!(
5885 self.has_recoverable_work(&path, Disposable::BuildOutput),
5886 Ok(false)
5887 ) {
5888 return None;
5889 }
5890 match self.remove_worktree_at(&path) {
5891 Ok(true) => released = Some(path),
5892 _ => return None,
5893 }
5894 }
5895 match self.delete_branch_if_safe(&branch) {
5896 Ok(true) => {
5897 self.forget_branch(&branch);
5898 Some(match released {
5899 Some(path) => format!(
5900 " The worktree at {} was released and branch {branch} deleted.",
5901 path.display()
5902 ),
5903 None => format!(" Branch {branch} was deleted."),
5904 })
5905 }
5906 _ => released.map(|path| {
5907 format!(
5908 " The worktree at {} was released, but branch {branch} was kept.",
5909 path.display()
5910 )
5911 }),
5912 }
5913 }
5914
5915 pub fn merge_pr(&self, number: i64) -> Result<()> {
5919 let n = number.to_string();
5920 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5921 Ok(_) => Ok(()),
5922 Err(e) => {
5923 if self.pr_state(number) == "MERGED" {
5924 let finished = self
5925 .finish_merged_branch_cleanup(number)
5926 .unwrap_or_default();
5927 logdim!(
5928 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5929 e.last_line()
5930 );
5931 Ok(())
5932 } else {
5933 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5934 }
5935 }
5936 };
5937 self.record_write(merged)
5938 }
5939
5940 pub fn merge_pr_at_head(
5942 &self,
5943 number: i64,
5944 expected_head: &str,
5945 delete_branch: bool,
5946 ) -> Result<()> {
5947 let n = number.to_string();
5948 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5949 Ok(_) => Ok(()),
5950 Err(e) => {
5951 if self.pr_state(number) == "MERGED" {
5952 let finished = self
5953 .finish_merged_branch_cleanup(number)
5954 .unwrap_or_default();
5955 logdim!(
5956 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5957 e.last_line()
5958 );
5959 Ok(())
5960 } else {
5961 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5962 }
5963 }
5964 };
5965 self.record_write(merged)
5966 }
5967
5968 pub fn followups_path(&self) -> PathBuf {
5973 self.root.join(STATE_DIR).join("followups.md")
5974 }
5975
5976 pub fn brainstorms_dir(&self) -> PathBuf {
5981 self.root.join(STATE_DIR).join("brainstorms")
5982 }
5983
5984 pub fn worked_followups_path(&self) -> PathBuf {
5992 self.root.join(STATE_DIR).join("followups.done.md")
5993 }
5994
5995 pub fn spend_path(&self) -> PathBuf {
5998 self.root.join(STATE_DIR).join("state").join("spend.json")
5999 }
6000
6001 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
6007 if calls.is_empty() {
6008 return Ok(());
6009 }
6010 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
6011 .ok()
6012 .and_then(|text| serde_json::from_str(&text).ok())
6013 .unwrap_or_default();
6014 all.extend(calls.iter().cloned());
6015 if let Some(parent) = self.spend_path().parent() {
6016 let _ = std::fs::create_dir_all(parent);
6017 }
6018 write_json_atomic(&self.spend_path(), &all)
6019 }
6020
6021 pub fn implementors_path(&self) -> PathBuf {
6027 self.root
6028 .join(STATE_DIR)
6029 .join("state")
6030 .join("implementors.json")
6031 }
6032
6033 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
6034 std::fs::read_to_string(self.implementors_path())
6035 .ok()
6036 .and_then(|text| serde_json::from_str(&text).ok())
6037 .unwrap_or_default()
6038 }
6039
6040 pub fn remember_implementor(&self, issue: i64, agent: &str) {
6043 let mut known = self.read_implementors();
6044 known.insert(issue, agent.to_string());
6045 if let Some(parent) = self.implementors_path().parent() {
6046 let _ = std::fs::create_dir_all(parent);
6047 }
6048 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
6049 logdim!("could not record who implemented #{issue}: {e}");
6050 }
6051 }
6052
6053 pub fn contested_path(&self) -> PathBuf {
6058 self.root
6059 .join(STATE_DIR)
6060 .join("state")
6061 .join("contested.json")
6062 }
6063
6064 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
6066 std::fs::read_to_string(self.contested_path())
6067 .ok()
6068 .and_then(|text| serde_json::from_str(&text).ok())
6069 .unwrap_or_default()
6070 }
6071
6072 pub fn remember_contested(&self, items: &[ContestedItem]) {
6077 if items.is_empty() {
6078 return;
6079 }
6080 let mut known = self.read_contested();
6081 for item in items {
6082 known.insert(
6083 item.issue,
6084 Contested {
6085 title: item.title.clone(),
6086 positions: item.positions.clone(),
6087 reasons: item.reasons.clone(),
6088 },
6089 );
6090 }
6091 if let Some(parent) = self.contested_path().parent() {
6092 let _ = std::fs::create_dir_all(parent);
6093 }
6094 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
6095 logdim!("could not record the contested issues: {e}");
6096 }
6097 }
6098
6099 pub fn forget_contested(&self, numbers: &[i64]) {
6101 let mut known = self.read_contested();
6102 let before = known.len();
6103 known.retain(|number, _| !numbers.contains(number));
6104 if known.len() == before {
6105 return;
6106 }
6107 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
6108 logdim!("could not update the contested issues: {e}");
6109 }
6110 }
6111
6112 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
6113 self.root
6114 .join(STATE_DIR)
6115 .join("state")
6116 .join(format!("checkin-{number}.json"))
6117 }
6118
6119 pub fn answer_state_path(&self, number: i64) -> PathBuf {
6121 self.root
6122 .join(STATE_DIR)
6123 .join("state")
6124 .join(format!("answer-{number}.json"))
6125 }
6126
6127 pub fn answer_notes_path(&self, number: i64) -> PathBuf {
6130 self.root
6131 .join(STATE_DIR)
6132 .join("answers")
6133 .join(format!("issue-{number}.md"))
6134 }
6135
6136 pub fn review_record_path(&self, number: i64) -> PathBuf {
6138 self.root
6139 .join(STATE_DIR)
6140 .join("state")
6141 .join(format!("review-{number}.json"))
6142 }
6143
6144 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
6147 let path = self.review_record_path(number);
6148 let text = std::fs::read_to_string(&path).ok()?;
6149 match serde_json::from_str(&text) {
6150 Ok(record) => Some(record),
6151 Err(e) => {
6152 logdim!("could not read {}: {e}", path.display());
6153 None
6154 }
6155 }
6156 }
6157
6158 pub fn write_review_record(
6159 &self,
6160 number: i64,
6161 record: &crate::model::ReviewRecord,
6162 ) -> Result<()> {
6163 write_json_atomic(&self.review_record_path(number), record)
6164 }
6165
6166 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
6181 let path = self.followups_path();
6182 let heading = format!("## {}", title.trim());
6183 for seen in [&path, &self.worked_followups_path()] {
6184 if let Ok(existing) = std::fs::read_to_string(seen) {
6185 if has_heading(&existing, title) {
6186 logdim!("follow-up already noted: {title}");
6187 return Followup::Covered(format!("note: {}", title.trim()));
6188 }
6189 }
6190 }
6191 if let Some(parent) = path.parent() {
6192 let _ = std::fs::create_dir_all(parent);
6193 }
6194 use std::io::Write;
6195 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
6202 match std::fs::OpenOptions::new()
6203 .create(true)
6204 .append(true)
6205 .open(&path)
6206 {
6207 Ok(mut file) => match file.write_all(entry.as_bytes()) {
6208 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
6209 Err(e) => {
6210 logdim!("could not write {}: {e}", path.display());
6211 Followup::Failed
6212 }
6213 },
6214 Err(e) => {
6215 logdim!("could not write {}: {e}", path.display());
6216 Followup::Failed
6217 }
6218 }
6219 }
6220
6221 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
6226 let path = self.worked_followups_path();
6227 if let Some(parent) = path.parent() {
6228 let _ = std::fs::create_dir_all(parent);
6229 }
6230 use std::io::Write;
6231 let entry = format!(
6232 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
6233 title.trim(),
6234 body.trim()
6235 );
6236 if let Ok(mut file) = std::fs::OpenOptions::new()
6237 .create(true)
6238 .append(true)
6239 .open(&path)
6240 {
6241 let _ = file.write_all(entry.as_bytes());
6242 }
6243 }
6244
6245 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
6254 self.root
6255 .join(STATE_DIR)
6256 .join("reviews")
6257 .join(format!("pr-{number}.md"))
6258 }
6259
6260 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
6266 let path = self.pending_comment_path(number);
6267 if let Some(parent) = path.parent() {
6268 std::fs::create_dir_all(parent)
6269 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6270 }
6271 std::fs::write(&path, text)
6272 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6273 Ok(path)
6274 }
6275
6276 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
6277 std::fs::read_to_string(self.pending_comment_path(number)).ok()
6278 }
6279
6280 pub fn state_path(&self, number: i64) -> PathBuf {
6281 self.root
6282 .join(STATE_DIR)
6283 .join("state")
6284 .join(format!("pr-{number}.json"))
6285 }
6286
6287 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
6288 let path = self.state_path(number);
6289 let text = std::fs::read_to_string(&path).ok()?;
6290 match serde_json::from_str(&text) {
6291 Ok(state) => Some(state),
6292 Err(_) => {
6293 logdim!("could not read {}, starting fresh", path.display());
6294 None
6295 }
6296 }
6297 }
6298
6299 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
6300 if let Some(local) = self.read_local_state(pr.number) {
6301 return Some(local);
6302 }
6303 if self.state_store.writes_pr() {
6304 return self.read_pr_state(pr.number);
6305 }
6306 None
6307 }
6308
6309 pub(crate) fn read_state_for_head(
6310 &self,
6311 pr: &PrView,
6312 actual_head: &str,
6313 ) -> Option<PersistedState> {
6314 let local = self
6315 .state_store
6316 .writes_local()
6317 .then(|| self.read_local_state(pr.number))
6318 .flatten();
6319 let remote = self
6320 .state_store
6321 .writes_pr()
6322 .then(|| self.read_pr_state(pr.number))
6323 .flatten();
6324 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
6325 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
6326 self.remember_checkpoint(pr.number, checkpoint);
6327 }
6328 choose_state_for_head(candidates, actual_head)
6329 }
6330
6331 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
6332 self.try_read_pr_state(number).ok().flatten()
6333 }
6334
6335 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
6336 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
6337 if let Some(state) = parse_state_comment(&body) {
6338 return Ok(Some(state));
6339 }
6340 }
6341 Ok(None)
6342 }
6343
6344 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6345 let remote_state = if self.state_store.writes_pr() {
6346 self.try_read_pr_state(number)
6347 } else {
6348 Ok(None)
6349 };
6350 self.write_state_after_remote_read(number, state, remote_state)
6351 }
6352
6353 fn write_state_after_remote_read(
6354 &self,
6355 number: i64,
6356 state: &PersistedState,
6357 remote_state: Result<Option<PersistedState>>,
6358 ) -> Result<()> {
6359 let remote_checkpoint = if self.state_store.writes_pr() {
6360 self.record_failed_write(remote_state)?
6361 .map(|saved| saved.checkpoint)
6362 .unwrap_or_default()
6363 } else {
6364 0
6365 };
6366 let local_checkpoint = self
6367 .state_store
6368 .writes_local()
6369 .then(|| self.read_local_state(number))
6370 .flatten()
6371 .map(|saved| saved.checkpoint)
6372 .unwrap_or_default();
6373 let mut stamped = state.clone();
6374 stamped.checkpoint = state
6375 .checkpoint
6376 .max(local_checkpoint)
6377 .max(remote_checkpoint)
6378 .max(self.remembered_checkpoint(number))
6379 .saturating_add(1);
6380 self.remember_checkpoint(number, stamped.checkpoint);
6381 if self.state_store.writes_local() {
6382 write_json_atomic(&self.state_path(number), &stamped)?;
6383 }
6384 if self.state_store.writes_pr() {
6385 self.write_pr_state(number, &stamped)?;
6386 }
6387 Ok(())
6388 }
6389
6390 fn remembered_checkpoint(&self, number: i64) -> u64 {
6391 self.checkpoints
6392 .lock()
6393 .unwrap_or_else(std::sync::PoisonError::into_inner)
6394 .get(&number)
6395 .copied()
6396 .unwrap_or_default()
6397 }
6398
6399 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6400 let mut checkpoints = self
6401 .checkpoints
6402 .lock()
6403 .unwrap_or_else(std::sync::PoisonError::into_inner);
6404 let saved = checkpoints.entry(number).or_default();
6405 *saved = (*saved).max(checkpoint);
6406 }
6407
6408 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6409 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6413 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6414 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6415 if let Some(id) = comment_id {
6416 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6417 let field = format!("body={body}");
6418 let written = self
6419 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6420 .map(|_| ());
6421 return self.record_write(written);
6422 }
6423 let written = self
6424 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6425 .map(|_| ());
6426 self.record_write(written)
6427 }
6428
6429 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6437 self.try_issue_comments(number).unwrap_or_default()
6438 }
6439
6440 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6441 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6442 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6443 }
6444
6445 pub fn issue_row(&self, number: i64) -> Result<Value> {
6449 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6450 let text = self
6451 .gh(&["api", &path])
6452 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6453 serde_json::from_str(&text)
6454 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6455 }
6456
6457 pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6460 #[derive(Deserialize)]
6461 struct Row {
6462 number: i64,
6463 }
6464 let name = self
6466 .repo_name()
6467 .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6468 let text = self.gh(&[
6469 "search",
6470 "issues",
6471 "--repo",
6472 &name,
6473 "--mentions",
6474 login,
6475 "--state",
6476 "open",
6477 "--limit",
6478 &FETCH_CEILING.to_string(),
6479 "--json",
6480 "number",
6481 ])?;
6482 let rows: Vec<Row> = serde_json::from_str(text.trim())
6483 .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6484 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6485 numbers.sort_unstable();
6486 numbers.truncate(limit);
6487 Ok(numbers)
6488 }
6489
6490 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6497 let viewer = self.viewer_login()?.to_string();
6498 let mut mine = Vec::new();
6499 let mut theirs = Vec::new();
6500 for comment in self.try_issue_comments(number)? {
6501 let Some(body) = comment.get("body").and_then(Value::as_str) else {
6502 continue;
6503 };
6504 if !body.contains("spar:state") {
6505 continue;
6506 }
6507 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6508 continue;
6509 };
6510 let author = comment
6511 .get("user")
6512 .and_then(|u| u.get("login"))
6513 .and_then(Value::as_str)
6514 .unwrap_or_default();
6515 if is_state_comment(body, author, &viewer) {
6516 mine.push((id, body.to_string()));
6517 } else {
6518 theirs.push(format!("@{author}"));
6519 }
6520 }
6521 if !theirs.is_empty() {
6522 logdim!(
6523 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6524 own state block is patched or deleted.",
6525 theirs.len(),
6526 theirs.join(", ")
6527 );
6528 }
6529 Ok(mine)
6530 }
6531
6532 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6533 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6534 }
6535
6536 pub fn clear_state(&self, number: i64) {
6538 let path = self.state_path(number);
6539 let _ = std::fs::remove_file(&path);
6540 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6541 }
6542
6543 pub fn prune_state(&self) -> Vec<String> {
6550 let base = self.root.join(STATE_DIR).join("state");
6551 let Ok(entries) = std::fs::read_dir(&base) else {
6552 return Vec::new();
6553 };
6554 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6555 for name in entries
6556 .flatten()
6557 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6558 {
6559 if let Some(number) = state_file_number(&name) {
6560 by_number.entry(number).or_default().push(name);
6561 }
6562 }
6563
6564 let mut removed = Vec::new();
6565 for (number, mut names) in by_number {
6566 if !is_finished(&self.pr_state(number)) {
6567 continue;
6568 }
6569 names.sort();
6570 for name in names {
6571 let _ = std::fs::remove_file(base.join(&name));
6572 removed.push(format!("state {name}"));
6573 }
6574 }
6575 removed
6576 }
6577
6578 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6582 #[derive(Deserialize)]
6583 struct Row {
6584 number: i64,
6585 }
6586 let numbers = match numbers {
6587 Some(numbers) => numbers,
6588 None => {
6589 let listed: Result<Vec<i64>> = (|| {
6590 let text = self.gh(&[
6591 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6592 ])?;
6593 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6594 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6595 Ok(rows.into_iter().map(|row| row.number).collect())
6596 })();
6597 match self.record_failed_write(listed) {
6598 Ok(numbers) => numbers,
6599 Err(e) => {
6600 logdim!("could not inspect pull requests for state cleanup: {e}");
6601 return Vec::new();
6602 }
6603 }
6604 }
6605 };
6606
6607 let mut removed = Vec::new();
6608 for number in numbers {
6609 let state = match self.record_failed_write(self.try_pr_state(number)) {
6610 Ok(state) => state,
6611 Err(e) => {
6612 logdim!("could not inspect PR #{number} for state cleanup: {e}");
6613 continue;
6614 }
6615 };
6616 if !is_finished(&state) {
6617 continue;
6618 }
6619 let comments = match self.record_failed_write(self.try_state_comments(number)) {
6620 Ok(comments) => comments,
6621 Err(e) => {
6622 logdim!("could not inspect state comments on PR #{number}: {e}");
6623 continue;
6624 }
6625 };
6626 for (id, _) in comments {
6627 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6628 let deleted = self
6629 .gh(&["api", "-X", "DELETE", &path, "--silent"])
6630 .map(|_| ());
6631 match self.record_write(deleted) {
6632 Ok(()) => removed.push(format!("state comment on PR #{number}")),
6633 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6634 }
6635 }
6636 }
6637 removed
6638 }
6639
6640 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6647 let base = self.root.join(WORKTREE_DIR);
6648 let mut removed = Vec::new();
6649 let known = self.known_branches();
6650
6651 if let Ok(entries) = std::fs::read_dir(&base) {
6652 let mut names: Vec<String> = entries
6653 .flatten()
6654 .filter(|e| e.path().is_dir())
6655 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6656 .collect();
6657 names.sort();
6658
6659 for name in names {
6660 if let Some(owner) = read_checkout_owner(&name) {
6664 let path = base.join(&name);
6665 if force_all {
6666 self.remove_worktree_at_force(&path);
6667 } else if process_is_running(owner) {
6668 continue;
6669 } else if has_recovery_marker(&path) {
6670 logdim!("kept {} for recovery", path.display());
6671 continue;
6672 } else {
6673 self.discard_read_checkout(&path);
6674 }
6675 if !path.exists() {
6676 removed.push(name);
6677 }
6678 continue;
6679 }
6680 if let Some(rest) = name.strip_prefix("review-") {
6683 let number: i64 = rest.parse().unwrap_or(-1);
6684 if !(force_all || is_finished(&self.pr_state(number))) {
6685 continue;
6686 }
6687 let path = base.join(&name);
6688 if force_all {
6689 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6690 if !belongs {
6691 return Ok(false);
6692 }
6693 let local_ref = review_ref(number);
6694 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6695 return Ok(false);
6696 }
6697 let head = self.head_oid_checked(&path)?;
6698 let recorded = self
6699 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6700 .trim()
6701 .to_string();
6702 Ok(head == recorded)
6703 });
6704 match owned {
6705 Ok(true) => {}
6706 Ok(false) => {
6707 logdim!(
6708 "kept {} because no matching SPAR review reference proves \
6709 ownership",
6710 path.display()
6711 );
6712 continue;
6713 }
6714 Err(e) => {
6715 logdim!(
6716 "kept {} because review ownership could not be verified: {}",
6717 path.display(),
6718 e.last_line()
6719 );
6720 continue;
6721 }
6722 }
6723 } else {
6724 if let Err(e) = self.refuse_review_worktree_changes(number) {
6725 logdim!(
6726 "kept {} because its review state could not be verified as \
6727 disposable: {}",
6728 path.display(),
6729 e.last_line()
6730 );
6731 continue;
6732 }
6733 }
6734 if force_all {
6735 if self.remove_worktree_at_force(&path) {
6736 self.git_try(&["update-ref", "-d", &review_ref(number)]);
6737 }
6738 } else {
6739 self.release_review_worktree(number);
6740 }
6741 if !path.exists() {
6742 removed.push(name);
6743 }
6744 continue;
6745 }
6746 let branch = format!("{}{name}", self.branch_prefix);
6747 if !(force_all || self.worktree_is_done(&branch)) {
6748 continue;
6749 }
6750 if !known.contains_key(&branch) {
6751 logdim!("kept {branch} because it has no branch record");
6752 continue;
6753 }
6754 let path = base.join(&name);
6755 if !force_all {
6756 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6757 Ok(true) => {
6758 logdim!(
6759 "kept {} because it contains uncommitted changes or ignored files",
6760 path.display()
6761 );
6762 continue;
6763 }
6764 Err(e) => {
6765 logdim!(
6766 "kept {} because its Git state could not be checked: {}",
6767 path.display(),
6768 e.last_line()
6769 );
6770 continue;
6771 }
6772 Ok(false) => {}
6773 }
6774 match self.branch_deletion_is_safe(&branch) {
6775 Ok(true) => {}
6776 Ok(false) => {
6777 logdim!(
6778 "kept {branch} because no surviving ref preserves its tip or \
6779 reflog-only commits"
6780 );
6781 continue;
6782 }
6783 Err(e) => {
6784 logdim!(
6785 "kept {branch} because preservation could not be verified: {}",
6786 e.last_line()
6787 );
6788 continue;
6789 }
6790 }
6791 }
6792 let removed_worktree = if force_all {
6793 self.remove_worktree_at_force(&path)
6794 } else {
6795 match self.remove_worktree_at(&path) {
6796 Ok(removed) => removed,
6797 Err(error) => {
6798 logdim!(
6799 "kept {branch} and {} because removal did not reach a confirmed \
6800 quiet point: {}",
6801 path.display(),
6802 error.last_line()
6803 );
6804 false
6805 }
6806 }
6807 };
6808 if !removed_worktree {
6809 continue;
6810 }
6811 if force_all {
6812 self.git_try(&["branch", "-D", &branch]);
6813 self.forget_branch(&branch);
6814 } else {
6815 match self.delete_branch_if_safe(&branch) {
6816 Ok(true) => self.forget_branch(&branch),
6817 Ok(false) => logdim!(
6818 "kept {branch} because its tip or reflog changed before deletion"
6819 ),
6820 Err(error) => logdim!(
6821 "kept {branch} because deletion safety could not be rechecked: {}",
6822 error.last_line()
6823 ),
6824 }
6825 }
6826 removed.push(name);
6827 }
6828 }
6829 removed.extend(self.prune_branches(force_all));
6830 removed
6831 }
6832
6833 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6840 let known = self.known_branches();
6841 let branches: Vec<String> = known.keys().cloned().collect();
6842 if branches.is_empty() {
6843 return Vec::new();
6844 }
6845
6846 let checked_out: Vec<String> = self
6847 .git_try(&["worktree", "list", "--porcelain"])
6848 .lines()
6849 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6850 .collect();
6851
6852 let existing: Vec<String> = self
6855 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6856 .lines()
6857 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6858 .collect();
6859
6860 let mut removed = Vec::new();
6861 for branch in branches {
6862 if !existing.contains(&branch) {
6863 self.forget_branch(&branch); continue;
6865 }
6866 if checked_out.contains(&branch) {
6867 continue;
6868 }
6869 if !(force_all || self.worktree_is_done(&branch)) {
6870 continue;
6871 }
6872 if !force_all {
6873 let Some(_record) = known.get(&branch) else {
6874 continue;
6875 };
6876 match self.branch_deletion_is_safe(&branch) {
6877 Ok(true) => {}
6878 Ok(false) => {
6879 logdim!(
6880 "kept {branch} because no surviving ref preserves its tip or \
6881 reflog-only commits"
6882 );
6883 continue;
6884 }
6885 Err(e) => {
6886 logdim!(
6887 "kept {branch} because preservation could not be verified: {}",
6888 e.last_line()
6889 );
6890 continue;
6891 }
6892 }
6893 }
6894 let deleted = if force_all {
6895 self.git(&["branch", "-D", &branch]).map(|_| true)
6896 } else {
6897 self.delete_branch_if_safe(&branch)
6898 };
6899 match deleted {
6900 Ok(true) => {
6901 self.forget_branch(&branch);
6902 removed.push(format!("branch {branch}"));
6903 }
6904 Ok(false) => {
6905 logdim!("kept {branch} because its tip or reflog changed before deletion");
6906 }
6907 Err(e) => {
6908 logdim!("could not delete {branch}: {}", e.last_line());
6911 }
6912 }
6913 }
6914 removed
6915 }
6916
6917 fn worktree_is_done(&self, branch: &str) -> bool {
6919 #[derive(Deserialize)]
6920 struct Row {
6921 state: String,
6922 }
6923 let entry = branch
6924 .strip_prefix(self.branch_prefix.as_str())
6925 .unwrap_or(branch);
6926 if let Some(rest) = entry.strip_prefix("pr-") {
6927 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6928 }
6929 if entry.starts_with("issue-") || entry.starts_with("split-") {
6933 let text = self.gh_try(&[
6934 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6935 ]);
6936 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6937 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6938 }
6939 false
6940 }
6941}
6942
6943pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6955 match probe() {
6956 Ok(value) => Ok(value),
6957 Err(first) => {
6958 logdim!("retrying a worktree probe after: {}", first.last_line());
6959 probe()
6960 }
6961 }
6962}
6963
6964pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6970 let root = std::fs::canonicalize(cwd)
6971 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6972 let mut files = BTreeMap::new();
6973 let mut visited = BTreeSet::new();
6974 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6975 Ok(AttributeState { files })
6976}
6977
6978fn collect_attribute_files(
6979 root: &Path,
6980 repository: &Path,
6981 prefix: &Path,
6982 visited: &mut BTreeSet<PathBuf>,
6983 files: &mut BTreeMap<PathBuf, [u8; 32]>,
6984) -> Result<()> {
6985 let canonical = std::fs::canonicalize(repository)
6986 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6987 if !visited.insert(canonical) {
6988 bail!("submodule recursion revisited {}", repository.display());
6989 }
6990 let entries = index_entries(repository)?;
6991 let mut paths: BTreeSet<PathBuf> = entries
6992 .iter()
6993 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6994 .map(|entry| entry.path.clone())
6995 .collect();
6996 paths.extend(untracked_attribute_paths(repository)?);
7002 for path in paths {
7003 let from_root = prefix.join(&path);
7004 let state = attribute_file_fingerprint(&root.join(&from_root))?;
7005 files.insert(from_root, state);
7006 }
7007 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
7008 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
7009 continue;
7010 };
7011 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
7012 }
7013 Ok(())
7014}
7015
7016pub(crate) fn uncertain_worktree_change(
7019 cwd: &Path,
7020 message: impl Into<String>,
7021) -> crate::error::SparError {
7022 let message = message.into();
7023 let marker = write_recovery_marker(cwd, &message);
7024 let note = match marker {
7025 Ok(path) => format!(" Recovery marker: {}.", path.display()),
7026 Err(e) => format!(
7027 " A recovery marker could not be written: {}.",
7028 e.last_line()
7029 ),
7030 };
7031 crate::error::SparError::uncertain_write(format!("{message}{note}"))
7032}
7033
7034fn has_recovery_marker(cwd: &Path) -> bool {
7035 let Ok(entries) = std::fs::read_dir(cwd) else {
7036 return false;
7037 };
7038 entries.flatten().any(|entry| {
7039 entry
7040 .file_name()
7041 .to_str()
7042 .is_some_and(|name| name.starts_with(RECOVERY_MARKER_PREFIX))
7043 })
7044}
7045
7046fn read_checkout_owner(name: &str) -> Option<u32> {
7048 let rest = name.strip_prefix(READ_CHECKOUT_PREFIX)?;
7049 let (pid, serial) = rest.split_once('-')?;
7050 serial.parse::<usize>().ok()?;
7051 pid.parse().ok()
7052}
7053
7054#[cfg(unix)]
7060fn process_is_running(pid: u32) -> bool {
7061 let Ok(pid) = libc::pid_t::try_from(pid) else {
7062 return true;
7063 };
7064 if pid <= 0 {
7065 return true;
7066 }
7067 if unsafe { libc::kill(pid, 0) } == 0 {
7068 return true;
7069 }
7070 std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
7071}
7072
7073#[cfg(not(unix))]
7074fn process_is_running(_pid: u32) -> bool {
7075 true
7076}
7077
7078fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
7079 use std::sync::atomic::{AtomicU32, Ordering};
7080 static NEXT: AtomicU32 = AtomicU32::new(0);
7081 for _ in 0..1000 {
7082 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
7083 let path = cwd.join(format!(
7084 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
7085 std::process::id()
7086 ));
7087 let mut options = OpenOptions::new();
7088 options.write(true).create_new(true);
7089 #[cfg(unix)]
7090 {
7091 use std::os::unix::fs::OpenOptionsExt;
7092 options.mode(0o600);
7093 }
7094 match options.open(&path) {
7095 Ok(mut file) => {
7096 file.write_all(detail.as_bytes())
7097 .and_then(|_| file.write_all(b"\n"))
7098 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
7099 return Ok(path);
7100 }
7101 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
7102 Err(e) => {
7103 return Err(spar_err!(
7104 "could not create a recovery marker in {}: {e}",
7105 cwd.display()
7106 ))
7107 }
7108 }
7109 }
7110 bail!(
7111 "could not choose a free recovery marker name in {}",
7112 cwd.display()
7113 )
7114}
7115
7116fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
7121 let mut argv = vec![
7122 "git".to_string(),
7123 "-c".to_string(),
7124 "maintenance.auto=false".to_string(),
7125 "-c".to_string(),
7126 "gc.auto=0".to_string(),
7127 ];
7128 argv.extend(args.iter().map(|arg| (*arg).to_string()));
7129 argv
7130}
7131
7132fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
7133 let mut argv = git_without_maintenance_argv(&[]);
7134 argv.extend([
7135 "-c".to_string(),
7136 "core.fsmonitor=".to_string(),
7137 "-c".to_string(),
7138 "commit.gpgsign=false".to_string(),
7139 "-c".to_string(),
7140 "core.hooksPath=/dev/null".to_string(),
7141 ]);
7142 argv.extend(args.iter().map(|arg| (*arg).to_string()));
7143 argv
7144}
7145
7146pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
7153 let root = std::fs::canonicalize(cwd)
7154 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7155 let mut files = BTreeMap::new();
7156 let mut ignored = BTreeSet::new();
7157 let mut visited = BTreeSet::new();
7158 collect_untracked_files(
7159 &root,
7160 &root,
7161 Path::new(""),
7162 &mut visited,
7163 &mut files,
7164 &mut ignored,
7165 )?;
7166 Ok(IgnoredState { files, ignored })
7167}
7168
7169fn collect_untracked_files(
7170 root: &Path,
7171 repository: &Path,
7172 prefix: &Path,
7173 visited: &mut BTreeSet<PathBuf>,
7174 files: &mut BTreeMap<PathBuf, UntrackedFile>,
7175 ignored: &mut BTreeSet<PathBuf>,
7176) -> Result<()> {
7177 let canonical = std::fs::canonicalize(repository)
7178 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7179 if !visited.insert(canonical.clone()) {
7180 bail!("submodule recursion revisited {}", canonical.display());
7181 }
7182 let listed = run_git_bytes(
7186 repository,
7187 &["ls-files", "--others", "--exclude-standard", "-z"],
7188 )?;
7189 if !listed.is_empty() && !listed.ends_with(&[0]) {
7190 bail!(
7191 "git returned an unterminated untracked-file list for {}",
7192 repository.display()
7193 );
7194 }
7195
7196 for raw in listed
7197 .split(|byte| *byte == 0)
7198 .filter(|raw| !raw.is_empty())
7199 {
7200 let (relative, nested) = untracked_record(raw, "untracked")?;
7201 let from_root = prefix.join(&relative);
7202 if is_spar_own_path(&from_root) {
7203 continue;
7204 }
7205 let absolute = root.join(&from_root);
7206 let fingerprint = if nested {
7207 nested_repository_fingerprint(&absolute)?
7208 } else {
7209 ignored_file_fingerprint(&absolute)?
7210 };
7211 if files.insert(from_root.clone(), fingerprint).is_some() {
7212 bail!(
7213 "git returned the untracked path more than once: {:?}",
7214 from_root
7215 );
7216 }
7217 }
7218
7219 let ignored_listed = run_git_bytes(
7220 repository,
7221 &[
7222 "ls-files",
7223 "--others",
7224 "--ignored",
7225 "--exclude-standard",
7226 "-z",
7227 ],
7228 )?;
7229 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
7230 bail!(
7231 "git returned an unterminated ignored-file list for {}",
7232 repository.display()
7233 );
7234 }
7235 for raw in ignored_listed
7241 .split(|byte| *byte == 0)
7242 .filter(|raw| !raw.is_empty())
7243 {
7244 let (relative, nested) = untracked_record(raw, "ignored")?;
7245 let from_root = prefix.join(relative);
7246 if is_spar_own_path(&from_root) {
7247 continue;
7248 }
7249 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
7250 let absolute = root.join(&from_root);
7251 let fingerprint = if nested {
7252 nested_repository_fingerprint(&absolute)?
7253 } else {
7254 ignored_file_fingerprint(&absolute)?
7255 };
7256 slot.insert(fingerprint);
7257 }
7258 if !ignored.insert(from_root.clone()) {
7259 bail!(
7260 "git returned the ignored path more than once: {:?}",
7261 from_root
7262 );
7263 }
7264 }
7265
7266 for link in gitlinks(repository)? {
7267 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7268 continue;
7269 };
7270 collect_untracked_files(
7271 root,
7272 &submodule,
7273 &prefix.join(&link.path),
7274 visited,
7275 files,
7276 ignored,
7277 )?;
7278 }
7279 Ok(())
7280}
7281
7282fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
7283 let argv = git_without_automation_argv(args);
7284 proc::run_bytes(
7285 &argv,
7286 &ExecOpts::new()
7287 .cwd(cwd)
7288 .timeout_secs(30)
7289 .stop_descendants(true),
7290 )
7291}
7292
7293fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
7294 let argv = git_without_automation_argv(args);
7295 proc::run(
7296 &argv,
7297 &ExecOpts::new()
7298 .cwd(cwd)
7299 .timeout_secs(30)
7300 .stop_descendants(true),
7301 )
7302}
7303
7304fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
7305 let path = path.to_str().ok_or_else(|| {
7306 spar_err!(
7307 "cannot verify filtered content for a non-UTF-8 path in {}",
7308 cwd.display()
7309 )
7310 })?;
7311 let path_arg = format!("--path={path}");
7312 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
7313 Ok(Sha256::digest(bytes).into())
7314}
7315
7316fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
7317 let relative = path_from_git_bytes(raw)?;
7318 if relative.is_absolute()
7319 || relative.components().any(|component| {
7320 matches!(
7321 component,
7322 std::path::Component::ParentDir
7323 | std::path::Component::RootDir
7324 | std::path::Component::Prefix(_)
7325 )
7326 })
7327 {
7328 bail!("git returned an unsafe {kind} path: {:?}", relative);
7329 }
7330 Ok(relative)
7331}
7332
7333fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
7342 let nested = raw.last() == Some(&b'/');
7343 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
7344 if trimmed.is_empty() {
7345 bail!("git returned an empty {kind} path");
7346 }
7347 Ok((safe_git_path(trimmed, kind)?, nested))
7348}
7349
7350fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
7351 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
7352 if !listed.is_empty() && !listed.ends_with(&[0]) {
7353 bail!(
7354 "git returned an unterminated index listing for {}",
7355 cwd.display()
7356 );
7357 }
7358 let mut entries = Vec::new();
7359 for record in listed
7360 .split(|byte| *byte == 0)
7361 .filter(|record| !record.is_empty())
7362 {
7363 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7364 bail!(
7365 "git returned a malformed index record for {}",
7366 cwd.display()
7367 );
7368 };
7369 let header = &record[..tab];
7370 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
7371 if fields.len() != 3 {
7372 bail!(
7373 "git returned a malformed index header for {}",
7374 cwd.display()
7375 );
7376 }
7377 if fields[2] != b"0" {
7378 continue;
7379 }
7380 let mode = std::str::from_utf8(fields[0])
7381 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
7382 .to_string();
7383 let oid = std::str::from_utf8(fields[1])
7384 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7385 .to_string();
7386 entries.push(IndexEntry {
7387 path: safe_git_path(&record[tab + 1..], "index")?,
7388 mode,
7389 oid,
7390 });
7391 }
7392 Ok(entries)
7393}
7394
7395fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
7397 let listed = run_git_bytes(
7398 cwd,
7399 &[
7400 "ls-files",
7401 "--others",
7402 "--exclude-standard",
7403 "-z",
7404 "--",
7405 ".gitattributes",
7406 ":(glob)**/.gitattributes",
7407 ],
7408 )?;
7409 if !listed.is_empty() && !listed.ends_with(&[0]) {
7410 bail!(
7411 "git returned an unterminated attribute-file listing for {}",
7412 cwd.display()
7413 );
7414 }
7415 listed
7416 .split(|byte| *byte == 0)
7417 .filter(|record| !record.is_empty())
7418 .map(|raw| safe_git_path(raw, "attribute"))
7419 .collect()
7420}
7421
7422fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
7423 if !untracked_attribute_paths(cwd)?.is_empty() {
7424 return Ok(true);
7425 }
7426
7427 let index = index_entries(cwd)?
7428 .into_iter()
7429 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7430 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7431 .collect::<BTreeMap<_, _>>();
7432 let head = tree_entries(cwd, "HEAD")?
7433 .into_iter()
7434 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7435 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7436 .collect::<BTreeMap<_, _>>();
7437 if index != head {
7438 return Ok(true);
7439 }
7440
7441 let effective = check_attributes(cwd, index.keys().cloned())?;
7442 let config = CheckoutConfig::read(cwd)?;
7443 for (path, (_mode, oid)) in index {
7444 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
7445 return Ok(true);
7446 };
7447 let attributes = effective
7448 .get(&path)
7449 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
7450 if allows_expected_crlf(&config, attributes)? {
7451 if worktree.mode == "120000" {
7452 return Ok(true);
7453 }
7454 let (normalized, every_lf_was_crlf) =
7455 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7456 if !every_lf_was_crlf || normalized != oid {
7457 return Ok(true);
7458 }
7459 } else if worktree.raw_oid != oid {
7460 return Ok(true);
7461 }
7462 }
7463 Ok(false)
7464}
7465
7466fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7467 Ok(index_entries(cwd)?
7468 .into_iter()
7469 .filter(|entry| entry.mode == "160000")
7470 .map(|entry| Gitlink {
7471 path: entry.path,
7472 oid: entry.oid,
7473 })
7474 .collect())
7475}
7476
7477fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7478 let mut tracked = BTreeMap::new();
7479 for entry in index_entries(cwd)? {
7480 if entry.mode == "160000" {
7481 continue;
7482 }
7483 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7484 tracked.insert(
7485 entry.path,
7486 TrackedEntry {
7487 index_mode: entry.mode,
7488 index_oid: entry.oid,
7489 worktree,
7490 },
7491 );
7492 }
7493 Ok(tracked)
7494}
7495
7496fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7497 let metadata = match std::fs::symlink_metadata(path) {
7498 Ok(metadata) => metadata,
7499 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7500 Err(e) => {
7501 return Err(spar_err!(
7502 "could not inspect tracked file {}: {e}",
7503 path.display()
7504 ))
7505 }
7506 };
7507 let mut fingerprint = Sha256::new();
7508 if metadata.file_type().is_symlink() {
7509 let target = std::fs::read_link(path)
7510 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7511 let bytes = os_str_bytes(target.as_os_str())?;
7512 fingerprint.update(b"symlink\0");
7513 fingerprint.update(&bytes);
7514 let content = Sha256::digest(&bytes).into();
7515 return Ok(Some(WorktreeFile {
7516 mode: "120000".to_string(),
7517 #[cfg(unix)]
7518 permissions: 0,
7519 raw_oid: git_blob_oid(oid_len, &bytes)?,
7520 fingerprint: fingerprint.finalize().into(),
7521 content,
7522 }));
7523 }
7524 if !metadata.is_file() {
7525 bail!("tracked path {} is not a file or symlink", path.display());
7526 }
7527
7528 let mut options = OpenOptions::new();
7529 options.read(true);
7530 #[cfg(unix)]
7531 {
7532 use std::os::unix::fs::OpenOptionsExt;
7533 options.custom_flags(libc::O_NOFOLLOW);
7534 }
7535 let mut file = options
7536 .open(path)
7537 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7538 let before = file
7539 .metadata()
7540 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7541 let mode = tracked_file_mode(&before);
7542 #[cfg(unix)]
7543 let permissions = {
7544 use std::os::unix::fs::MetadataExt;
7545 before.mode() & 0o7777
7546 };
7547 fingerprint.update(b"file\0");
7548 fingerprint.update(mode.as_bytes());
7549 #[cfg(unix)]
7550 fingerprint.update(permissions.to_le_bytes());
7551 fingerprint.update(before.len().to_le_bytes());
7552 let mut content = Sha256::new();
7553 let header = format!("blob {}\0", before.len());
7554 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7555 let mut buf = [0u8; 64 * 1024];
7556 loop {
7557 let read = file
7558 .read(&mut buf)
7559 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7560 if read == 0 {
7561 break;
7562 }
7563 fingerprint.update(&buf[..read]);
7564 content.update(&buf[..read]);
7565 object.update(&buf[..read]);
7566 }
7567 let after = file
7568 .metadata()
7569 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7570 if before.len() != after.len()
7571 || before.modified().ok() != after.modified().ok()
7572 || before.permissions() != after.permissions()
7573 {
7574 bail!(
7575 "tracked file {} changed while it was being inspected",
7576 path.display()
7577 );
7578 }
7579 let current = std::fs::symlink_metadata(path)
7580 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7581 if !same_file(&after, ¤t) {
7582 bail!(
7583 "tracked file {} was replaced while it was being inspected",
7584 path.display()
7585 );
7586 }
7587 Ok(Some(WorktreeFile {
7588 mode,
7589 #[cfg(unix)]
7590 permissions,
7591 raw_oid: object.finish(),
7592 fingerprint: fingerprint.finalize().into(),
7593 content: content.finalize().into(),
7594 }))
7595}
7596
7597fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7598 let metadata = std::fs::symlink_metadata(path)
7599 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7600 let mut digest = Sha256::new();
7601 if metadata.file_type().is_symlink() {
7602 digest.update(b"symlink\0");
7603 let target = std::fs::read_link(path)
7604 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7605 digest.update(os_str_bytes(target.as_os_str())?);
7606 return Ok(digest.finalize().into());
7607 }
7608 if !metadata.is_file() {
7609 bail!("attribute path {} is not a file or symlink", path.display());
7610 }
7611 let mut options = OpenOptions::new();
7612 options.read(true);
7613 #[cfg(unix)]
7614 {
7615 use std::os::unix::fs::OpenOptionsExt;
7616 options.custom_flags(libc::O_NOFOLLOW);
7617 }
7618 let mut file = options
7619 .open(path)
7620 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7621 let before = file
7622 .metadata()
7623 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7624 digest.update(b"file\0");
7625 let mut buf = [0u8; 64 * 1024];
7626 loop {
7627 let read = file
7628 .read(&mut buf)
7629 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7630 if read == 0 {
7631 break;
7632 }
7633 digest.update(&buf[..read]);
7634 }
7635 let after = file
7636 .metadata()
7637 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7638 let current = std::fs::symlink_metadata(path)
7639 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7640 if before.len() != after.len()
7641 || before.modified().ok() != after.modified().ok()
7642 || !same_file(&after, ¤t)
7643 {
7644 bail!(
7645 "attribute file {} changed while it was being inspected",
7646 path.display()
7647 );
7648 }
7649 Ok(digest.finalize().into())
7650}
7651
7652enum ObjectHasher {
7653 Sha1(Sha1),
7654 Sha256(Sha256),
7655}
7656
7657impl ObjectHasher {
7658 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7659 let mut hasher = match oid_len {
7660 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7661 64 => Self::Sha256(Sha256::new()),
7662 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7663 };
7664 hasher.update(header);
7665 Ok(hasher)
7666 }
7667
7668 fn update(&mut self, bytes: &[u8]) {
7669 match self {
7670 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7671 Self::Sha256(hasher) => hasher.update(bytes),
7672 }
7673 }
7674
7675 fn finish(self) -> String {
7676 let bytes = match self {
7677 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7678 Self::Sha256(hasher) => hasher.finalize().to_vec(),
7679 };
7680 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7681 }
7682}
7683
7684fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7685 let header = format!("blob {}\0", bytes.len());
7686 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7687 hasher.update(bytes);
7688 Ok(hasher.finish())
7689}
7690
7691fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7692 let mut first = open_regular_file(path)?;
7693 let first_before = first
7694 .metadata()
7695 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7696 let mut raw_len = 0u64;
7697 let mut crlf_pairs = 0u64;
7698 let mut previous_was_cr = false;
7699 let mut every_lf_was_crlf = true;
7700 let mut buf = [0u8; 64 * 1024];
7701 loop {
7702 let read = first
7703 .read(&mut buf)
7704 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7705 if read == 0 {
7706 break;
7707 }
7708 raw_len = raw_len
7709 .checked_add(read as u64)
7710 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7711 for byte in &buf[..read] {
7712 if *byte == b'\n' {
7713 if previous_was_cr {
7714 crlf_pairs += 1;
7715 } else {
7716 every_lf_was_crlf = false;
7717 }
7718 }
7719 previous_was_cr = *byte == b'\r';
7720 }
7721 }
7722 let first_after = first
7723 .metadata()
7724 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7725 let current = std::fs::symlink_metadata(path)
7726 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7727 if raw_len != first_before.len()
7728 || !stable_file_metadata(&first_before, &first_after)
7729 || !stable_file_metadata(&first_after, ¤t)
7730 {
7731 bail!(
7732 "tracked file {} changed while line endings were inspected",
7733 path.display()
7734 );
7735 }
7736
7737 let normalized_len = raw_len
7738 .checked_sub(crlf_pairs)
7739 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7740 let header = format!("blob {normalized_len}\0");
7741 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7742 let mut second = open_regular_file(path)?;
7743 let second_before = second
7744 .metadata()
7745 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7746 if !stable_file_metadata(&first_after, &second_before) {
7747 bail!(
7748 "tracked file {} changed between line-ending checks",
7749 path.display()
7750 );
7751 }
7752 let mut pending_cr = false;
7753 loop {
7754 let read = second
7755 .read(&mut buf)
7756 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7757 if read == 0 {
7758 break;
7759 }
7760 for byte in &buf[..read] {
7761 if pending_cr {
7762 if *byte == b'\n' {
7763 object.update(b"\n");
7764 pending_cr = false;
7765 continue;
7766 }
7767 object.update(b"\r");
7768 pending_cr = false;
7769 }
7770 if *byte == b'\r' {
7771 pending_cr = true;
7772 } else {
7773 object.update(std::slice::from_ref(byte));
7774 }
7775 }
7776 }
7777 if pending_cr {
7778 object.update(b"\r");
7779 }
7780 let second_after = second
7781 .metadata()
7782 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7783 let current = std::fs::symlink_metadata(path)
7784 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7785 if !stable_file_metadata(&second_before, &second_after)
7786 || !stable_file_metadata(&second_after, ¤t)
7787 {
7788 bail!(
7789 "tracked file {} changed while line endings were hashed",
7790 path.display()
7791 );
7792 }
7793 Ok((object.finish(), every_lf_was_crlf))
7794}
7795
7796fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7797 let mut options = OpenOptions::new();
7798 options.read(true);
7799 #[cfg(unix)]
7800 {
7801 use std::os::unix::fs::OpenOptionsExt;
7802 options.custom_flags(libc::O_NOFOLLOW);
7803 }
7804 let file = options
7805 .open(path)
7806 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7807 let metadata = file
7808 .metadata()
7809 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7810 if !metadata.is_file() {
7811 bail!("tracked path {} is not a regular file", path.display());
7812 }
7813 Ok(file)
7814}
7815
7816fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7817 if !same_file(left, right)
7818 || left.len() != right.len()
7819 || left.modified().ok() != right.modified().ok()
7820 || left.permissions() != right.permissions()
7821 {
7822 return false;
7823 }
7824 #[cfg(unix)]
7825 {
7826 use std::os::unix::fs::MetadataExt;
7827 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7828 }
7829 #[cfg(not(unix))]
7830 {
7831 left.created().ok() == right.created().ok()
7832 }
7833}
7834
7835const ATTRIBUTE_BATCH: usize = 1000;
7842
7843fn check_attributes(
7844 cwd: &Path,
7845 paths: impl IntoIterator<Item = PathBuf>,
7846) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7847 let paths = paths.into_iter().collect::<BTreeSet<_>>();
7848 if paths.is_empty() {
7849 return Ok(BTreeMap::new());
7850 }
7851 let ordered = paths.into_iter().collect::<Vec<_>>();
7852 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7853 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7854 values.extend(check_attribute_batch(cwd, batch)?);
7855 }
7856 Ok(values)
7857}
7858
7859fn check_attribute_batch(
7860 cwd: &Path,
7861 batch: &[PathBuf],
7862) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7863 const NAMES: [&str; 6] = [
7864 "filter",
7865 "working-tree-encoding",
7866 "ident",
7867 "text",
7868 "eol",
7869 "crlf",
7870 ];
7871 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7872 let mut input = String::new();
7873 for path in &paths {
7874 let path = path.to_str().ok_or_else(|| {
7875 spar_err!(
7876 "cannot inspect attributes for a non-UTF-8 path in {}",
7877 cwd.display()
7878 )
7879 })?;
7880 input.push_str(path);
7881 input.push('\0');
7882 }
7883 let argv = git_without_automation_argv(&[
7884 "check-attr",
7885 "-z",
7886 "--cached",
7887 "--stdin",
7888 "filter",
7889 "working-tree-encoding",
7890 "ident",
7891 "text",
7892 "eol",
7893 "crlf",
7894 ]);
7895 let output = proc::run_bytes(
7896 &argv,
7897 &ExecOpts::new()
7898 .cwd(cwd)
7899 .timeout_secs(30)
7900 .stdin(input)
7901 .stop_descendants(true),
7902 )?;
7903 if !output.is_empty() && !output.ends_with(&[0]) {
7904 bail!(
7905 "git returned an unterminated attribute result for {}",
7906 cwd.display()
7907 );
7908 }
7909 let fields = output
7910 .split(|byte| *byte == 0)
7911 .filter(|field| !field.is_empty())
7912 .collect::<Vec<_>>();
7913 if fields.len() != paths.len() * NAMES.len() * 3 {
7914 bail!(
7915 "git returned an unexpected attribute result for {}",
7916 cwd.display()
7917 );
7918 }
7919 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7920 for record in fields.chunks_exact(3) {
7921 let path = safe_git_path(record[0], "attribute")?;
7922 if !paths.contains(&path) {
7923 bail!(
7924 "git returned attributes for the wrong path in {}",
7925 cwd.display()
7926 );
7927 }
7928 let name = std::str::from_utf8(record[1])
7929 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7930 let value = std::str::from_utf8(record[2])
7931 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7932 values
7933 .entry(path)
7934 .or_default()
7935 .insert(name.to_string(), value.to_string());
7936 }
7937 if paths.iter().any(|path| {
7938 values
7939 .get(path)
7940 .is_none_or(|attributes| attributes.len() != NAMES.len())
7941 }) {
7942 bail!(
7943 "git omitted an attribute result for a tracked path in {}",
7944 cwd.display()
7945 );
7946 }
7947 Ok(values)
7948}
7949
7950fn attribute_is_active(value: Option<&String>) -> bool {
7951 !matches!(
7952 value.map(String::as_str),
7953 None | Some("unspecified") | Some("unset")
7954 )
7955}
7956
7957fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7958 attribute_is_active(values.get("filter"))
7959 || attribute_is_active(values.get("working-tree-encoding"))
7960}
7961
7962struct CheckoutConfig {
7971 autocrlf: Option<String>,
7972 eol: Option<String>,
7973 symlinks: Option<bool>,
7974}
7975
7976impl CheckoutConfig {
7977 fn read(cwd: &Path) -> Result<Self> {
7978 Ok(Self {
7979 autocrlf: git_config_value(cwd, "core.autocrlf")?,
7980 eol: git_config_value(cwd, "core.eol")?,
7981 symlinks: git_config_bool(cwd, "core.symlinks")?,
7982 })
7983 }
7984
7985 fn autocrlf_is_true(&self) -> bool {
7986 self.autocrlf.as_ref().is_some_and(|value| {
7987 matches!(
7988 value.to_ascii_lowercase().as_str(),
7989 "true" | "yes" | "on" | "1"
7990 )
7991 })
7992 }
7993
7994 fn eol_is(&self, wanted: &str) -> bool {
7995 self.eol
7996 .as_ref()
7997 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7998 }
7999}
8000
8001fn path_has_ambiguous_transform(
8002 config: &CheckoutConfig,
8003 values: &BTreeMap<String, String>,
8004) -> Result<bool> {
8005 if path_has_external_transform(values)
8006 || attribute_is_active(values.get("ident"))
8007 || attribute_is_active(values.get("crlf"))
8008 {
8009 return Ok(true);
8010 }
8011 let text = values.get("text").map(String::as_str);
8012 let eol = values.get("eol").map(String::as_str);
8013 if text == Some("auto") {
8014 return Ok(true);
8015 }
8016 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
8017 || !matches!(
8018 eol,
8019 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
8020 )
8021 {
8022 return Ok(true);
8023 }
8024 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
8025 return Ok(config.autocrlf_is_true());
8026 }
8027 Ok(false)
8028}
8029
8030fn allows_expected_crlf(
8031 config: &CheckoutConfig,
8032 values: &BTreeMap<String, String>,
8033) -> Result<bool> {
8034 if path_has_external_transform(values)
8035 || attribute_is_active(values.get("ident"))
8036 || attribute_is_active(values.get("crlf"))
8037 {
8038 return Ok(false);
8039 }
8040 let text = values.get("text").map(String::as_str);
8041 let eol = values.get("eol").map(String::as_str);
8042 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
8043 return Ok(false);
8044 }
8045 if eol == Some("crlf") {
8046 return Ok(true);
8047 }
8048 if text != Some("set") {
8049 return Ok(false);
8050 }
8051 if let Some(autocrlf) = config.autocrlf.as_deref() {
8052 match autocrlf.to_ascii_lowercase().as_str() {
8053 "true" | "yes" | "on" | "1" => return Ok(true),
8054 "input" => return Ok(false),
8055 _ => {}
8056 }
8057 }
8058 if config.eol_is("crlf") {
8059 return Ok(true);
8060 }
8061 #[cfg(windows)]
8062 if config.eol.is_none() || config.eol_is("native") {
8063 return Ok(true);
8064 }
8065 Ok(false)
8066}
8067
8068fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
8069 let argv = git_without_automation_argv(&["config", "--get", key]);
8070 let output = proc::exec(
8071 &argv,
8072 &ExecOpts::new()
8073 .cwd(cwd)
8074 .timeout_secs(30)
8075 .check(false)
8076 .stop_descendants(true),
8077 )?;
8078 match output.code {
8079 0 => Ok(Some(output.stdout.trim().to_string())),
8080 1 => Ok(None),
8081 _ => bail!(
8082 "could not read Git configuration in {}: {}",
8083 cwd.display(),
8084 output.stderr.trim()
8085 ),
8086 }
8087}
8088
8089fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
8090 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
8091 let output = proc::exec(
8092 &argv,
8093 &ExecOpts::new()
8094 .cwd(cwd)
8095 .timeout_secs(30)
8096 .check(false)
8097 .stop_descendants(true),
8098 )?;
8099 match output.code {
8100 0 if output.stdout.trim() == "true" => Ok(Some(true)),
8101 0 if output.stdout.trim() == "false" => Ok(Some(false)),
8102 0 => bail!(
8103 "git returned an invalid boolean for {key} in {}",
8104 cwd.display()
8105 ),
8106 1 => Ok(None),
8107 _ => bail!(
8108 "could not read Git configuration in {}: {}",
8109 cwd.display(),
8110 output.stderr.trim()
8111 ),
8112 }
8113}
8114
8115#[cfg(unix)]
8116fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
8117 use std::os::unix::fs::PermissionsExt;
8118 if metadata.permissions().mode() & 0o111 == 0 {
8119 "100644".to_string()
8120 } else {
8121 "100755".to_string()
8122 }
8123}
8124
8125#[cfg(not(unix))]
8126fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
8127 "100644".to_string()
8128}
8129
8130fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
8131 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
8132 if !listed.is_empty() && !listed.ends_with(&[0]) {
8133 bail!(
8134 "git returned an unterminated tree listing for {}",
8135 cwd.display()
8136 );
8137 }
8138 let mut entries = Vec::new();
8139 for record in listed
8140 .split(|byte| *byte == 0)
8141 .filter(|record| !record.is_empty())
8142 {
8143 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
8144 bail!("git returned a malformed tree record for {}", cwd.display());
8145 };
8146 let fields = record[..tab]
8147 .split(|byte| *byte == b' ')
8148 .collect::<Vec<_>>();
8149 if fields.len() != 3 {
8150 bail!("git returned a malformed tree header for {}", cwd.display());
8151 }
8152 let mode = std::str::from_utf8(fields[0])
8153 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
8154 .to_string();
8155 let oid = std::str::from_utf8(fields[2])
8156 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
8157 .to_string();
8158 entries.push(IndexEntry {
8159 path: safe_git_path(&record[tab + 1..], "tree")?,
8160 mode,
8161 oid,
8162 });
8163 }
8164 Ok(entries)
8165}
8166
8167fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
8168 Ok(tree_entries(cwd, "HEAD")?
8169 .into_iter()
8170 .filter(|entry| entry.mode == "160000")
8171 .map(|entry| (entry.path, entry.oid))
8172 .collect())
8173}
8174
8175fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
8176 let head = head_gitlinks(cwd)?;
8177 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
8178 .into_iter()
8179 .map(|link| (link.path, link.oid))
8180 .collect();
8181 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
8182 paths.extend(index.keys().cloned());
8183 Ok(paths
8184 .into_iter()
8185 .filter(|path| head.get(path) != index.get(path))
8186 .collect())
8187}
8188
8189fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
8190 let path = parent.join(relative);
8191 let metadata = match std::fs::symlink_metadata(&path) {
8192 Ok(metadata) => metadata,
8193 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8194 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8195 };
8196 if !metadata.is_dir() {
8197 bail!("the gitlink at {} is not a directory", path.display());
8198 }
8199 let canonical = std::fs::canonicalize(&path)
8200 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
8201 if canonical != path {
8202 bail!(
8203 "the gitlink at {} resolves through a symlink",
8204 path.display()
8205 );
8206 }
8207 if !path.join(".git").exists() {
8208 let empty = std::fs::read_dir(&path)
8209 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
8210 .next()
8211 .is_none();
8212 if empty {
8213 return Ok(None);
8214 }
8215 bail!(
8216 "the uninitialized gitlink at {} contains local files",
8217 path.display()
8218 );
8219 }
8220 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
8221 if inside.trim() != "true" {
8222 bail!("the gitlink at {} is not a worktree", path.display());
8223 }
8224 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
8225 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
8226 spar_err!(
8227 "could not resolve the gitlink top level at {}: {e}",
8228 path.display()
8229 )
8230 })?;
8231 if top != canonical {
8232 bail!(
8233 "the gitlink at {} belongs to a different worktree",
8234 path.display()
8235 );
8236 }
8237 Ok(Some(canonical))
8238}
8239
8240fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
8254 let root = std::fs::canonicalize(cwd)
8255 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8256 let mut allowed = BTreeSet::from([root.join(".git")]);
8257 let mut checkouts = BTreeSet::new();
8258 let mut repositories = vec![root.clone()];
8259 let mut visited = BTreeSet::new();
8260 while let Some(repository) = repositories.pop() {
8261 let canonical = std::fs::canonicalize(&repository)
8262 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8263 if !visited.insert(canonical.clone()) {
8264 bail!("submodule recursion revisited {}", canonical.display());
8265 }
8266 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
8267 let (path, nested) = untracked_record(&raw, "untracked")?;
8268 if nested {
8269 checkouts.insert(canonical.join(path));
8270 }
8271 }
8272 for link in gitlinks(&canonical)? {
8273 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
8274 continue;
8275 };
8276 allowed.insert(submodule.join(".git"));
8277 repositories.push(submodule);
8278 }
8279 }
8280
8281 let scan_root = root.clone();
8285 let mut directories = vec![root];
8286 while let Some(directory) = directories.pop() {
8287 let entries = match std::fs::read_dir(&directory) {
8288 Ok(entries) => entries,
8289 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8290 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8291 };
8292 for entry in entries {
8293 let entry = match entry {
8294 Ok(entry) => entry,
8295 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8296 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8297 };
8298 let path = entry.path();
8299 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
8300 continue;
8301 }
8302 if entry.file_name() == OsStr::new(".git") {
8303 if !allowed.contains(&path) {
8304 return Ok(Some(path));
8305 }
8306 continue;
8307 }
8308 let kind = match entry.file_type() {
8309 Ok(kind) => kind,
8310 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8311 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8312 };
8313 if kind.is_dir() && !checkouts.contains(&path) {
8314 directories.push(path);
8315 }
8316 }
8317 }
8318 Ok(None)
8319}
8320
8321pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
8322 if let Some(path) = unexpected_nested_git_entry(cwd)? {
8323 bail!(
8324 "the worktree contains a Git entry at {} that Git does not report as a nested \
8325 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
8326 prove they are unchanged.",
8327 path.display()
8328 );
8329 }
8330 let root = std::fs::canonicalize(cwd)
8331 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8332 let mut repositories = BTreeMap::new();
8333 let mut visited = BTreeSet::new();
8334 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
8335 Ok(GitState { repositories })
8336}
8337
8338fn collect_git_state(
8339 repository: &Path,
8340 prefix: &Path,
8341 visited: &mut BTreeSet<PathBuf>,
8342 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
8343) -> Result<()> {
8344 let canonical = std::fs::canonicalize(repository)
8345 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8346 if !visited.insert(canonical.clone()) {
8347 bail!("submodule recursion revisited {}", canonical.display());
8348 }
8349 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8350 let head = head.trim().to_string();
8351 if head.is_empty() {
8352 bail!("git returned an empty head for {}", repository.display());
8353 }
8354 let unsafe_index_flags = unsafe_index_flags(repository)?;
8355 let tracked = tracked_entries(repository)?;
8356 let gitlinks = gitlinks(repository)?;
8357 if repositories
8358 .insert(
8359 prefix.to_path_buf(),
8360 RepositoryState {
8361 head,
8362 unsafe_index_flags,
8363 tracked,
8364 gitlinks: gitlinks
8365 .iter()
8366 .map(|link| (link.path.clone(), link.oid.clone()))
8367 .collect(),
8368 },
8369 )
8370 .is_some()
8371 {
8372 bail!("Git state contains duplicate repository path {:?}", prefix);
8373 }
8374
8375 for link in gitlinks {
8376 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
8377 continue;
8378 };
8379 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
8380 }
8381 Ok(())
8382}
8383
8384fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
8385 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
8386 if !listed.is_empty() && !listed.ends_with(&[0]) {
8387 bail!(
8388 "git returned an unterminated index-flag listing for {}",
8389 cwd.display()
8390 );
8391 }
8392 let mut unsafe_records = Vec::new();
8393 for record in listed
8394 .split(|byte| *byte == 0)
8395 .filter(|record| !record.is_empty())
8396 {
8397 if record.len() < 3 || record[1] != b' ' {
8398 bail!(
8399 "git returned a malformed index-flag record for {}",
8400 cwd.display()
8401 );
8402 }
8403 if record[0] != b'H' {
8404 unsafe_records.extend_from_slice(record);
8405 unsafe_records.push(0);
8406 }
8407 }
8408 Ok(unsafe_records)
8409}
8410
8411pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
8412 safe_git_state(cwd).map(|_| ())
8413}
8414
8415pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
8416 let state = git_state(cwd)?;
8417 if let Some((path, _repository)) = state
8418 .repositories
8419 .iter()
8420 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
8421 {
8422 let label = if path.as_os_str().is_empty() {
8423 cwd.to_path_buf()
8424 } else {
8425 cwd.join(path)
8426 };
8427 bail!(
8428 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
8429 SPAR cannot prove the working files are unchanged, so it was kept.",
8430 label.display()
8431 );
8432 }
8433 Ok(state)
8434}
8435
8436fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
8437 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
8438}
8439
8440fn repository_has_recoverable_work_with(
8441 cwd: &Path,
8442 include_ignored: bool,
8443 disposable: Disposable,
8444) -> Result<bool> {
8445 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
8446 return Ok(true);
8447 }
8448 let mut visited = BTreeSet::new();
8449 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
8450}
8451
8452fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
8453 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
8454 let git_dir = PathBuf::from(git_dir.trim());
8455 let git_dir = if git_dir.is_absolute() {
8456 git_dir
8457 } else {
8458 cwd.join(git_dir)
8459 };
8460 let git_dir = std::fs::canonicalize(&git_dir)
8461 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8462 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8463 Ok(_) => return Ok(true),
8464 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8465 Err(error) => {
8466 return Err(spar_err!(
8467 "could not inspect per-worktree configuration in {}: {error}",
8468 git_dir.display()
8469 ))
8470 }
8471 }
8472
8473 let orig_head = git_dir.join("ORIG_HEAD");
8474 match std::fs::symlink_metadata(&orig_head) {
8475 Ok(metadata) if metadata.is_file() => {
8476 let oid = std::fs::read_to_string(&orig_head)
8477 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8478 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8479 return Ok(true);
8480 };
8481 if !commit_has_shared_ref(cwd, &commit)? {
8482 return Ok(true);
8483 }
8484 }
8485 Ok(_) => return Ok(true),
8486 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8487 Err(error) => {
8488 return Err(spar_err!(
8489 "could not inspect {}: {error}",
8490 orig_head.display()
8491 ))
8492 }
8493 }
8494
8495 let edit_message = git_dir.join("COMMIT_EDITMSG");
8496 match std::fs::symlink_metadata(&edit_message) {
8497 Ok(metadata) if metadata.is_file() => {
8498 let draft = std::fs::read(&edit_message)
8499 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8500 if draft != head_commit_message(cwd)? {
8501 return Ok(true);
8502 }
8503 }
8504 Ok(_) => return Ok(true),
8505 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8506 Err(error) => {
8507 return Err(spar_err!(
8508 "could not inspect {}: {error}",
8509 edit_message.display()
8510 ))
8511 }
8512 }
8513
8514 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8515 return Ok(true);
8516 }
8517
8518 let local_refs = run_git_bytes(
8519 cwd,
8520 &[
8521 "for-each-ref",
8522 "--format=%(refname)",
8523 "refs/worktree",
8524 "refs/bisect",
8525 "refs/rewritten",
8526 ],
8527 )?;
8528 if !local_refs.is_empty() {
8529 return Ok(true);
8530 }
8531
8532 for entry in std::fs::read_dir(&git_dir)
8536 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8537 {
8538 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8539 let known = matches!(
8540 entry.file_name().to_str(),
8541 Some(
8542 "HEAD"
8543 | "ORIG_HEAD"
8544 | "COMMIT_EDITMSG"
8545 | "FETCH_HEAD"
8546 | "commondir"
8547 | "gitdir"
8548 | "index"
8549 | "logs"
8550 | "refs"
8551 )
8552 );
8553 if !known {
8554 return Ok(true);
8555 }
8556 }
8557
8558 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8559 if !commit_has_shared_ref(cwd, head.trim())? {
8560 return Ok(true);
8561 }
8562 Ok(false)
8563}
8564
8565fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8566 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8567 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8568 bail!(
8569 "git returned a commit without a message separator in {}",
8570 cwd.display()
8571 );
8572 };
8573 Ok(commit[split + 2..].to_vec())
8574}
8575
8576fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8577 let metadata = match std::fs::symlink_metadata(logs) {
8578 Ok(metadata) => metadata,
8579 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8580 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8581 };
8582 if !metadata.is_dir() {
8583 return Ok(true);
8584 }
8585 let mut files = Vec::new();
8586 let mut directories = vec![logs.to_path_buf()];
8587 while let Some(directory) = directories.pop() {
8588 for entry in std::fs::read_dir(&directory)
8589 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8590 {
8591 let entry =
8592 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8593 let path = entry.path();
8594 let kind = entry
8595 .file_type()
8596 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8597 if kind.is_dir() {
8598 directories.push(path);
8599 } else if kind.is_file() {
8600 files.push(path);
8601 } else {
8602 return Ok(true);
8603 }
8604 }
8605 }
8606
8607 let mut commits = BTreeSet::new();
8608 for path in files {
8609 if !collect_reflog_commits(cwd, &path, &mut commits)? {
8610 return Ok(true);
8611 }
8612 }
8613 for commit in commits {
8614 if !commit_has_shared_ref(cwd, &commit)? {
8615 return Ok(true);
8616 }
8617 }
8618 Ok(false)
8619}
8620
8621fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8625 let common = common_git_dir(cwd)?;
8626 let reflog = common.join("logs").join(refname);
8627 let metadata = match std::fs::symlink_metadata(&reflog) {
8628 Ok(metadata) => metadata,
8629 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8630 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8631 };
8632 if !metadata.is_file() {
8633 return Ok(false);
8634 }
8635 let mut commits = BTreeSet::new();
8636 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8637 return Ok(false);
8638 }
8639 for commit in commits {
8640 if is_ancestor(cwd, &commit, durable_tip)?
8641 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8642 {
8643 continue;
8644 }
8645 return Ok(false);
8646 }
8647 Ok(true)
8648}
8649
8650fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8651 let file = std::fs::File::open(path)
8652 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8653 for line in std::io::BufReader::new(file).lines() {
8654 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8655 let mut fields = line.splitn(3, ' ');
8656 let Some(old) = fields.next() else {
8657 return Ok(false);
8658 };
8659 let Some(new) = fields.next() else {
8660 return Ok(false);
8661 };
8662 if fields.next().is_none() {
8663 return Ok(false);
8664 }
8665 for oid in [old, new] {
8666 if oid.bytes().all(|byte| byte == b'0') {
8667 continue;
8668 }
8669 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8670 return Ok(false);
8671 };
8672 commits.insert(commit);
8673 }
8674 }
8675 Ok(true)
8676}
8677
8678fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8679 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8680 let path = PathBuf::from(raw.trim());
8681 let path = if path.is_absolute() {
8682 path
8683 } else {
8684 cwd.join(path)
8685 };
8686 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8687}
8688
8689fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8690 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8691 let output = proc::exec(
8692 &argv,
8693 &ExecOpts::new()
8694 .cwd(cwd)
8695 .timeout_secs(30)
8696 .check(false)
8697 .stop_descendants(true),
8698 )?;
8699 match output.code {
8700 0 => Ok(true),
8701 1 => Ok(false),
8702 _ => bail!("{}", proc::failure_message(&argv, &output)),
8703 }
8704}
8705
8706fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8707 let commit = format!("{oid}^{{commit}}");
8708 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8709 let output = proc::exec(
8710 &argv,
8711 &ExecOpts::new()
8712 .cwd(cwd)
8713 .timeout_secs(30)
8714 .check(false)
8715 .stop_descendants(true),
8716 )?;
8717 if output.code != 0 {
8718 return Ok(None);
8719 }
8720 let oid = output.stdout.trim();
8721 if oid.is_empty() {
8722 return Ok(None);
8723 }
8724 Ok(Some(oid.to_string()))
8725}
8726
8727fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8728 commit_has_shared_ref_except(cwd, oid, None)
8729}
8730
8731fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8732 let contains = format!("--contains={oid}");
8733 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8734 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8735 !record.is_empty()
8736 && !record.starts_with(b"refs/worktree/")
8737 && !record.starts_with(b"refs/bisect/")
8738 && !record.starts_with(b"refs/rewritten/")
8739 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8740 }))
8741}
8742
8743fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8758 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8759 for raw in &ordinary {
8760 let (path, _) = untracked_record(raw, "untracked")?;
8761 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8762 continue;
8763 }
8764 return Ok(true);
8765 }
8766 let listed = untracked_listing(
8767 cwd,
8768 &[
8769 "ls-files",
8770 "--others",
8771 "--ignored",
8772 "--exclude-standard",
8773 "-z",
8774 ],
8775 )?;
8776 for raw in listed {
8777 let (path, nested) = untracked_record(&raw, "ignored")?;
8778 if nested {
8782 return Ok(true);
8783 }
8784 if disposable == Disposable::BuildOutput
8785 && !is_generated_artifact(&path)
8786 && !is_desktop_metadata(&path)
8787 {
8788 return Ok(true);
8789 }
8790 }
8791 Ok(false)
8792}
8793
8794fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8795 let listed = run_git_bytes(cwd, args)?;
8796 if !listed.is_empty() && !listed.ends_with(&[0]) {
8797 bail!(
8798 "git returned an unterminated untracked-file list for {}",
8799 cwd.display()
8800 );
8801 }
8802 Ok(listed
8803 .split(|byte| *byte == 0)
8804 .filter(|raw| !raw.is_empty())
8805 .map(|raw| raw.to_vec())
8806 .collect())
8807}
8808
8809fn repository_has_recoverable_work_inner(
8810 cwd: &Path,
8811 include_ignored: bool,
8812 disposable: Disposable,
8813 visited: &mut BTreeSet<PathBuf>,
8814) -> Result<bool> {
8815 let canonical = std::fs::canonicalize(cwd)
8816 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8817 if !visited.insert(canonical.clone()) {
8818 bail!("submodule recursion revisited {}", canonical.display());
8819 }
8820 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8821 return Ok(true);
8822 }
8823 if !unsafe_index_flags(cwd)?.is_empty() {
8824 return Ok(true);
8825 }
8826 if attributes_may_be_modified(cwd)? {
8827 return Ok(true);
8828 }
8829 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8830 return Ok(true);
8831 }
8832 if include_ignored {
8833 let index = index_entries(cwd)?
8834 .into_iter()
8835 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8836 .collect::<BTreeMap<_, _>>();
8837 let head = tree_entries(cwd, "HEAD")?
8838 .into_iter()
8839 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8840 .collect::<BTreeMap<_, _>>();
8841 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8842 return Ok(true);
8843 }
8844 let tracked = tracked_entries(cwd)?;
8845 let effective = check_attributes(cwd, tracked.keys().cloned())?;
8846 let config = CheckoutConfig::read(cwd)?;
8847 for (path, entry) in tracked {
8848 let Some(worktree) = entry.worktree else {
8849 return Ok(true);
8850 };
8851 let attributes = effective.get(&path).ok_or_else(|| {
8852 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8853 })?;
8854 if path_has_ambiguous_transform(&config, attributes)? {
8855 return Ok(true);
8856 }
8857 let symlink_file = entry.index_mode == "120000"
8858 && worktree.mode == "100644"
8859 && worktree.raw_oid == entry.index_oid
8860 && config.symlinks == Some(false);
8861 if worktree.mode != entry.index_mode && !symlink_file {
8862 return Ok(true);
8863 }
8864 if entry.index_mode == "120000" {
8865 if worktree.raw_oid != entry.index_oid {
8866 return Ok(true);
8867 }
8868 continue;
8869 }
8870 #[cfg(unix)]
8871 {
8872 let expected = if entry.index_mode == "100755" {
8873 0o755
8874 } else {
8875 0o644
8876 };
8877 if worktree.permissions != expected {
8878 return Ok(true);
8879 }
8880 }
8881 if allows_expected_crlf(&config, attributes)? {
8882 let (normalized, every_lf_was_crlf) =
8883 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8884 if !every_lf_was_crlf || normalized != entry.index_oid {
8885 return Ok(true);
8886 }
8887 } else if worktree.raw_oid != entry.index_oid {
8888 return Ok(true);
8889 }
8890 }
8891 } else {
8892 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8893 if !run_git_bytes(cwd, &args)?.is_empty() {
8894 return Ok(true);
8895 }
8896 }
8897 for link in gitlinks(cwd)? {
8898 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8899 continue;
8900 };
8901 if include_ignored {
8906 return Ok(true);
8907 }
8908 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8909 if head.trim() != link.oid {
8910 return Ok(true);
8911 }
8912 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8913 {
8914 return Ok(true);
8915 }
8916 }
8917 Ok(false)
8918}
8919
8920pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8921 repository_has_recoverable_work(cwd, false)
8922}
8923
8924fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8925 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8926 Ok(!run_git_bytes(cwd, &args)?.is_empty())
8927}
8928
8929#[cfg(unix)]
8930fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8931 use std::os::unix::ffi::OsStringExt;
8932 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8933}
8934
8935#[cfg(not(unix))]
8936fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8937 String::from_utf8(raw.to_vec())
8938 .map(PathBuf::from)
8939 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8940}
8941
8942fn vanished_untracked_file() -> UntrackedFile {
8958 UntrackedFile {
8959 kind: 0,
8960 len: 0,
8961 modified: None,
8962 created: None,
8963 readonly: false,
8964 symlink_target: None,
8965 #[cfg(unix)]
8966 device: 0,
8967 #[cfg(unix)]
8968 inode: 0,
8969 #[cfg(unix)]
8970 mode: 0,
8971 #[cfg(unix)]
8972 change_seconds: 0,
8973 #[cfg(unix)]
8974 change_nanoseconds: 0,
8975 }
8976}
8977
8978fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8979 let metadata = match std::fs::symlink_metadata(path) {
8980 Ok(metadata) => metadata,
8981 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8982 Err(e) => {
8983 return Err(spar_err!(
8984 "could not inspect the nested repository at {}: {e}",
8985 path.display()
8986 ))
8987 }
8988 };
8989 if !metadata.is_dir() {
8990 bail!(
8991 "git reported {} as a nested repository, but it is not a directory",
8992 path.display()
8993 );
8994 }
8995 if std::fs::symlink_metadata(path.join(".git")).is_err() {
8996 bail!(
8997 "git reported {} as a nested repository, but it has no Git entry",
8998 path.display()
8999 );
9000 }
9001 #[cfg(unix)]
9002 {
9003 use std::os::unix::fs::MetadataExt;
9004 Ok(UntrackedFile {
9005 kind: 3,
9006 len: 0,
9007 modified: None,
9008 created: metadata.created().ok(),
9009 readonly: metadata.permissions().readonly(),
9010 symlink_target: None,
9011 device: metadata.dev(),
9012 inode: metadata.ino(),
9013 mode: metadata.mode(),
9014 change_seconds: 0,
9015 change_nanoseconds: 0,
9016 })
9017 }
9018 #[cfg(not(unix))]
9019 {
9020 Ok(UntrackedFile {
9021 kind: 3,
9022 len: 0,
9023 modified: None,
9024 created: metadata.created().ok(),
9025 readonly: metadata.permissions().readonly(),
9026 symlink_target: None,
9027 })
9028 }
9029}
9030
9031fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
9032 let metadata = match std::fs::symlink_metadata(path) {
9033 Ok(metadata) => metadata,
9034 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
9035 Err(e) => {
9036 return Err(spar_err!(
9037 "could not inspect untracked file {}: {e}",
9038 path.display()
9039 ))
9040 }
9041 };
9042 let kind = if metadata.file_type().is_symlink() {
9043 2
9044 } else if metadata.is_file() {
9045 1
9046 } else {
9047 bail!(
9048 "untracked path {} is not a regular file or symlink",
9049 path.display()
9050 );
9051 };
9052 let symlink_target = if kind == 2 {
9053 match std::fs::read_link(path) {
9054 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
9055 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
9056 return Ok(vanished_untracked_file())
9057 }
9058 Err(e) => {
9059 return Err(spar_err!(
9060 "could not read untracked symlink {}: {e}",
9061 path.display()
9062 ))
9063 }
9064 }
9065 } else {
9066 None
9067 };
9068 #[cfg(unix)]
9069 {
9070 use std::os::unix::fs::MetadataExt;
9071 Ok(UntrackedFile {
9072 kind,
9073 len: metadata.len(),
9074 modified: metadata.modified().ok(),
9075 created: metadata.created().ok(),
9076 readonly: metadata.permissions().readonly(),
9077 symlink_target,
9078 device: metadata.dev(),
9079 inode: metadata.ino(),
9080 mode: metadata.mode(),
9081 change_seconds: metadata.ctime(),
9082 change_nanoseconds: metadata.ctime_nsec(),
9083 })
9084 }
9085 #[cfg(not(unix))]
9086 {
9087 Ok(UntrackedFile {
9088 kind,
9089 len: metadata.len(),
9090 modified: metadata.modified().ok(),
9091 created: metadata.created().ok(),
9092 readonly: metadata.permissions().readonly(),
9093 symlink_target,
9094 })
9095 }
9096}
9097
9098#[cfg(unix)]
9099fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
9100 use std::os::unix::ffi::OsStrExt;
9101 Ok(value.as_bytes().to_vec())
9102}
9103
9104#[cfg(not(unix))]
9105fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
9106 value
9107 .to_str()
9108 .map(|value| value.as_bytes().to_vec())
9109 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
9110}
9111
9112#[cfg(unix)]
9113fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
9114 use std::os::unix::fs::MetadataExt;
9115 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
9116}
9117
9118#[cfg(not(unix))]
9119fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
9120 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
9121}
9122
9123#[derive(Debug, Clone, serde::Serialize, Deserialize)]
9124pub struct BranchRecord {
9125 pub kind: String,
9126 pub number: i64,
9127}
9128
9129pub fn review_ref(number: i64) -> String {
9132 format!("refs/spar/pr-{number}")
9133}
9134
9135pub fn is_finished(state: &str) -> bool {
9136 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
9137}
9138
9139pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
9146 if let Some(parent) = path.parent() {
9147 std::fs::create_dir_all(parent)
9148 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
9149 }
9150 let tmp = path.with_extension(format!(
9153 "{}.tmp",
9154 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
9155 ));
9156 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
9157 std::fs::rename(&tmp, path)
9158 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
9159 Ok(())
9160}
9161
9162pub fn has_heading(text: &str, title: &str) -> bool {
9169 let wanted = title.trim().to_lowercase();
9170 if wanted.is_empty() {
9171 return false;
9172 }
9173 text.lines()
9174 .filter_map(|line| line.trim().strip_prefix("## "))
9175 .any(|heading| heading.trim().to_lowercase() == wanted)
9176}
9177
9178pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
9181 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
9182}
9183
9184#[derive(Deserialize)]
9186#[serde(rename_all = "camelCase")]
9187pub struct LinkedPrRow {
9188 number: i64,
9189 #[serde(default)]
9190 url: String,
9191 #[serde(default)]
9192 title: String,
9193 #[serde(default)]
9194 closing_issues_references: Vec<IssueRef>,
9195}
9196
9197pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
9204 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
9205}
9206
9207pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
9209 rows.iter()
9210 .find(|row| {
9211 row.closing_issues_references
9212 .iter()
9213 .any(|linked| linked.number == issue)
9214 })
9215 .map(|row| PrRef {
9216 number: row.number,
9217 url: row.url.clone(),
9218 title: row.title.clone(),
9219 })
9220}
9221
9222pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
9224 pick_linked_pr(&linked_pr_rows(json)?, issue)
9225}
9226
9227pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
9234 if text.trim().is_empty() {
9235 return Err(spar_err!("GitHub returned no comment data"));
9236 }
9237 let mut out = Vec::new();
9238 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9239 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
9240 Value::Array(items) => out.extend(items),
9241 _ => return Err(spar_err!("unexpected non-array comment page")),
9242 }
9243 }
9244 Ok(out)
9245}
9246
9247pub fn parse_comment_pages(text: &str) -> Vec<Value> {
9248 let mut out = Vec::new();
9249 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9250 match value {
9251 Ok(Value::Array(items)) => out.extend(items),
9252 Ok(other) => out.push(other),
9253 Err(_) => break,
9254 }
9255 }
9256 out
9257}
9258
9259pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
9267 body.trim_start().starts_with(STATE_MARKER)
9268 && !viewer.trim().is_empty()
9269 && author.trim().eq_ignore_ascii_case(viewer.trim())
9270 && parse_state_comment(body).is_some()
9271}
9272
9273pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
9276 let marker = body.find(STATE_MARKER)?;
9277 let start = body[marker..].find('{')? + marker;
9278 let end = body.rfind('}')?;
9279 if end <= start {
9280 return None;
9281 }
9282 match serde_json::from_str(&body[start..=end]) {
9283 Ok(state) => Some(state),
9284 Err(_) => {
9285 logdim!("found a spar state comment but could not parse it");
9286 None
9287 }
9288 }
9289}
9290
9291fn choose_state_for_head(
9292 candidates: Vec<PersistedState>,
9293 actual_head: &str,
9294) -> Option<PersistedState> {
9295 let matching: Vec<PersistedState> = candidates
9296 .iter()
9297 .filter(|state| state.pr_head == actual_head)
9298 .cloned()
9299 .collect();
9300 if !matching.is_empty() {
9301 return newest_state(matching);
9302 }
9303 newest_state(candidates)
9304}
9305
9306fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
9307 candidates.into_iter().reduce(|best, candidate| {
9308 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
9309 candidate
9310 } else {
9311 best
9316 }
9317 })
9318}
9319
9320pub fn self_binary() -> Result<PathBuf> {
9326 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
9327 let path = PathBuf::from(path);
9328 if proc::is_executable(&path) {
9329 return Ok(path);
9330 }
9331 bail!(
9332 "SPAR_SELF_BIN is set to {}, which is not executable",
9333 path.display()
9334 );
9335 }
9336 std::env::current_exe()
9337 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
9338}
9339
9340fn bool_env(value: bool) -> &'static str {
9341 if value {
9342 "1"
9343 } else {
9344 "0"
9345 }
9346}
9347
9348pub fn sh_quote(text: &str) -> String {
9351 format!("'{}'", text.replace('\'', r"'\''"))
9352}
9353
9354pub fn style_from_env() -> Style {
9357 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
9358 Style {
9359 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
9360 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
9361 ..Style::permissive()
9362 }
9363}
9364
9365#[cfg(test)]
9366mod tests {
9367 use super::*;
9368 use crate::config::StateStore;
9369 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
9370 use std::process::Command;
9371
9372 fn repo_for_titles() -> Repo {
9373 Repo {
9374 root: PathBuf::from("/nonexistent"),
9375 style: Style::default(),
9376 branch_prefix: String::new(),
9377 state_store: StateStore::Local,
9378 followups: crate::config::Followups::Issues,
9379 drafts: Drafts::Never,
9380 fork_when_denied: true,
9381 setup: CheckCommand::default(),
9382 isolate_reads: true,
9383 keep_worktrees: false,
9384 fork: OnceLock::new(),
9385 viewer: OnceLock::new(),
9386 checkpoints: Mutex::new(BTreeMap::new()),
9387 writes: WriteStats::default(),
9388 }
9389 }
9390
9391 #[test]
9394 fn only_a_read_checkout_name_names_its_owner() {
9395 assert_eq!(Some(4242), read_checkout_owner("read-4242-0"));
9396 assert_eq!(None, read_checkout_owner("read-4242"));
9397 assert_eq!(None, read_checkout_owner("read-me-0"));
9398 assert_eq!(None, read_checkout_owner("read-4242-x"));
9399 assert_eq!(None, read_checkout_owner("review-4242"));
9400 }
9401
9402 #[test]
9403 fn write_results_accumulate_for_the_run() {
9404 let repo = repo_for_titles();
9405
9406 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
9407 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
9408
9409 assert_eq!(
9410 WriteSummary {
9411 attempted: 2,
9412 failed: 1,
9413 },
9414 repo.write_summary()
9415 );
9416 }
9417
9418 #[test]
9419 fn only_failed_write_preflights_join_the_summary() {
9420 let repo = repo_for_titles();
9421
9422 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
9423 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
9424
9425 assert_eq!(
9426 WriteSummary {
9427 attempted: 1,
9428 failed: 1,
9429 },
9430 repo.write_summary()
9431 );
9432 }
9433
9434 #[test]
9435 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
9436 let repo = repo_for_titles();
9437
9438 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
9439 assert_eq!(
9440 WriteSummary {
9441 attempted: 1,
9442 failed: 1,
9443 },
9444 repo.write_summary()
9445 );
9446 }
9447
9448 #[test]
9449 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
9450 let mut repo = repo_for_titles();
9451 repo.followups = Followups::Local;
9452
9453 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
9454 assert_eq!(WriteSummary::default(), repo.write_summary());
9455 }
9456
9457 #[test]
9458 fn a_failed_remote_state_read_stops_before_state_mutation() {
9459 let root = std::env::temp_dir().join(format!(
9460 "spar-state-preflight-{}-{}",
9461 std::process::id(),
9462 std::time::SystemTime::now()
9463 .duration_since(std::time::UNIX_EPOCH)
9464 .unwrap()
9465 .as_nanos()
9466 ));
9467 std::fs::create_dir_all(&root).unwrap();
9468 let _fixture = ReviewFixture { root: root.clone() };
9469 let mut repo = repo_for_titles();
9470 repo.root = root;
9471 repo.state_store = StateStore::Both;
9472 let state = PersistedState {
9473 version: 1,
9474 checkpoint: 4,
9475 round: 2,
9476 next_actor: "a".into(),
9477 status: Status::Pending,
9478 pr_head: "abc123".into(),
9479 ledger: Ledger::new(),
9480 filed: Vec::new(),
9481 open_findings: Vec::new(),
9482 disputes: Vec::new(),
9483 noted: Vec::new(),
9484 };
9485
9486 let error = repo
9487 .write_state_after_remote_read(
9488 7,
9489 &state,
9490 Err(crate::error::SparError::new("state comments unavailable")),
9491 )
9492 .unwrap_err();
9493
9494 assert!(error.to_string().contains("state comments unavailable"));
9495 assert!(!repo.state_path(7).exists());
9496 assert_eq!(0, repo.remembered_checkpoint(7));
9497 assert_eq!(
9498 WriteSummary {
9499 attempted: 1,
9500 failed: 1,
9501 },
9502 repo.write_summary()
9503 );
9504 }
9505
9506 #[test]
9507 fn only_known_build_and_cache_directories_are_generated_artifacts() {
9508 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9509 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9510 assert!(is_generated_artifact(Path::new(
9511 "package/node_modules/dependency/file.js"
9512 )));
9513 assert!(!is_generated_artifact(Path::new(
9514 "distribution/required-package.js"
9515 )));
9516 assert!(!is_generated_artifact(Path::new(
9517 "generated/required-fixture.txt"
9518 )));
9519 assert!(!is_generated_artifact(Path::new("local.env")));
9520 }
9521
9522 #[test]
9523 fn only_finder_state_files_are_desktop_metadata() {
9524 assert!(is_desktop_metadata(Path::new(".DS_Store")));
9525 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9526 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9527 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9528 }
9529
9530 struct ReviewFixture {
9531 root: PathBuf,
9532 }
9533
9534 impl Drop for ReviewFixture {
9535 fn drop(&mut self) {
9536 let _ = std::fs::remove_dir_all(&self.root);
9537 }
9538 }
9539
9540 fn test_git(cwd: &Path, args: &[&str]) -> String {
9541 let output = Command::new("git")
9542 .args(args)
9543 .current_dir(cwd)
9544 .output()
9545 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9546 assert!(
9547 output.status.success(),
9548 "git {args:?} failed: {}",
9549 String::from_utf8_lossy(&output.stderr)
9550 );
9551 String::from_utf8_lossy(&output.stdout).into_owned()
9552 }
9553
9554 #[test]
9558 fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9559 let root =
9560 std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9561 let _cleanup = ReviewFixture { root: root.clone() };
9562 let origin = root.join("origin.git");
9563 let work = root.join("work");
9564 let marker = root.join("textconv-ran");
9565 std::fs::create_dir_all(&origin).unwrap();
9566 std::fs::create_dir_all(&work).unwrap();
9567 test_git(&origin, &["init", "--bare", "-b", "main"]);
9568 test_git(&work, &["init", "-b", "main"]);
9569 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9570 test_git(&work, &["config", "user.name", "spar test"]);
9571 test_git(&work, &["config", "commit.gpgsign", "false"]);
9572 test_git(&work, &["config", "diff.noprefix", "true"]);
9573 test_git(&work, &["config", "diff.context", "0"]);
9574 test_git(&work, &["config", "diff.interHunkContext", "10"]);
9575 test_git(&work, &["config", "diff.algorithm", "patience"]);
9576 test_git(&work, &["config", "color.diff", "always"]);
9577 test_git(
9578 &work,
9579 &[
9580 "config",
9581 "diff.conv.textconv",
9582 &format!("touch {}; cat", marker.display()),
9583 ],
9584 );
9585 std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9586 let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9587 std::fs::write(work.join("src.txt"), &lines).unwrap();
9588 test_git(&work, &["add", "."]);
9589 test_git(&work, &["commit", "-m", "seed"]);
9590 test_git(
9591 &work,
9592 &["remote", "add", "origin", origin.to_str().unwrap()],
9593 );
9594 test_git(&work, &["push", "-u", "origin", "main"]);
9595 test_git(&work, &["checkout", "-b", "feature"]);
9596 std::fs::write(
9597 work.join("src.txt"),
9598 lines
9599 .replace("line 5\n", "changed 5\n")
9600 .replace("line 15\n", "changed 15\n"),
9601 )
9602 .unwrap();
9603 std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9604 std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9605 test_git(&work, &["add", "."]);
9606 test_git(&work, &["commit", "-m", "change"]);
9607 let cfg = crate::config::parse(
9608 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9609 )
9610 .unwrap();
9611 let repo = Repo::open(&work, &cfg).unwrap();
9612 assert_eq!(
9613 Hunks::from([
9614 ("new.txt".to_string(), vec![(1, 2)]),
9615 ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9616 ("with space.txt".to_string(), vec![(1, 1)]),
9617 ]),
9618 repo.diff_hunks(&work, "main")
9619 );
9620 assert!(!marker.exists(), "the text converter ran");
9621 test_git(&work, &["diff", "origin/main...HEAD"]);
9623 assert!(marker.exists(), "the fixture's converter never runs at all");
9624 }
9625
9626 fn review_fixture(
9627 tag: &str,
9628 number: i64,
9629 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9630 use std::sync::atomic::{AtomicU32, Ordering};
9631 static NEXT: AtomicU32 = AtomicU32::new(0);
9632 let id = NEXT.fetch_add(1, Ordering::Relaxed);
9633 let root =
9634 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9635 let origin = root.join("origin.git");
9636 let work = root.join("work");
9637 std::fs::create_dir_all(&origin).unwrap();
9638 std::fs::create_dir_all(&work).unwrap();
9639 test_git(&origin, &["init", "--bare", "-b", "main"]);
9640 test_git(&work, &["init", "-b", "main"]);
9641 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9642 test_git(&work, &["config", "user.name", "spar test"]);
9643 test_git(&work, &["config", "commit.gpgsign", "false"]);
9644 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9645 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9646 std::fs::write(work.join("README.md"), "seed\n").unwrap();
9647 std::fs::write(work.join("data.txt"), "old\n").unwrap();
9648 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9649 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9650 test_git(&work, &["add", "."]);
9651 test_git(&work, &["commit", "-m", "seed"]);
9652 test_git(
9653 &work,
9654 &["remote", "add", "origin", origin.to_str().unwrap()],
9655 );
9656 test_git(&work, &["push", "-u", "origin", "main"]);
9657 test_git(
9658 &work,
9659 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9660 );
9661 let cfg = crate::config::parse(
9662 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9663 )
9664 .unwrap();
9665 let repo = Repo::open(&work, &cfg).unwrap();
9666 let path = repo.worktree_for_pr_head(number).unwrap();
9667 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9668 (ReviewFixture { root }, repo, path, checkpoint)
9669 }
9670
9671 #[test]
9672 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9673 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9674
9675 repo.release_review_worktree_checked(901, &checkpoint)
9676 .unwrap();
9677
9678 assert!(!path.exists());
9679 }
9680
9681 #[test]
9682 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9683 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9684 let work = fixture.root.join("work");
9685 let seed = test_git(&work, &["rev-parse", "HEAD"]);
9686 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9687 let publish = |message: &str| {
9690 let oid = test_git(
9691 &work,
9692 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9693 );
9694 let oid = oid.trim().to_string();
9695 test_git(
9696 &work,
9697 &[
9698 "push",
9699 "--force",
9700 "origin",
9701 &format!("{oid}:refs/pull/923/head"),
9702 ],
9703 );
9704 oid
9705 };
9706 let reviewed = publish("reviewed head");
9707 repo.worktree_for_pr_head(923).unwrap();
9708 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9709
9710 let rewritten = publish("rewritten head");
9711 repo.worktree_for_pr_head(923).unwrap();
9712
9713 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9714 }
9715
9716 #[test]
9717 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9718 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9719 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9720 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9721 test_git(&path, &["add", "recovery.txt"]);
9722 test_git(&path, &["commit", "-m", "recovery commit"]);
9723 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9724 test_git(&path, &["reset", "--hard", "main"]);
9725
9726 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9727 test_git(
9728 &path,
9729 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9730 );
9731 }
9732
9733 #[test]
9734 fn a_review_ref_reflog_only_commit_prevents_deletion() {
9735 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9736 let local_ref = review_ref(921);
9737 let original = test_git(&path, &["rev-parse", &local_ref]);
9738 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9739 let recovery = test_git(
9740 &path,
9741 &[
9742 "commit-tree",
9743 tree.trim(),
9744 "-p",
9745 original.trim(),
9746 "-m",
9747 "review ref recovery",
9748 ],
9749 );
9750 test_git(
9751 &path,
9752 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9753 );
9754 test_git(
9755 &path,
9756 &["update-ref", &local_ref, original.trim(), recovery.trim()],
9757 );
9758
9759 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9760 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9761 test_git(
9762 &path,
9763 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9764 );
9765 }
9766
9767 #[test]
9768 fn an_unpublished_commit_message_draft_is_recoverable() {
9769 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9770 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9771 let git_dir = if raw.is_absolute() {
9772 raw
9773 } else {
9774 path.join(raw)
9775 };
9776 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9777
9778 assert!(repository_has_recoverable_work(&path, true).unwrap());
9779 assert_eq!(
9780 "unique recovery draft\n",
9781 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9782 );
9783 }
9784
9785 #[test]
9786 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9787 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9788 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9789
9790 let error = repo
9791 .release_review_worktree_checked(902, &checkpoint)
9792 .unwrap_err();
9793
9794 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9795 assert!(error.to_string().contains("kept for recovery"), "{error}");
9796 assert_eq!(
9797 "recover me\n",
9798 std::fs::read_to_string(path.join("README.md")).unwrap()
9799 );
9800 repo.release_review_worktree(902);
9801 }
9802
9803 #[test]
9804 fn a_review_commit_is_retained_after_a_checked_read() {
9805 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9806 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9807 test_git(&path, &["add", "review-note.txt"]);
9808 test_git(&path, &["commit", "-m", "local review recovery"]);
9809 let head = test_git(&path, &["rev-parse", "HEAD"]);
9810
9811 let error = repo
9812 .release_review_worktree_checked(903, &checkpoint)
9813 .unwrap_err();
9814
9815 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9816 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9817 assert_eq!(
9818 "recover me\n",
9819 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9820 );
9821 repo.release_review_worktree(903);
9822 }
9823
9824 #[test]
9828 fn an_ignored_review_file_is_retained_after_a_checked_read() {
9829 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9830 std::fs::create_dir_all(path.join("generated")).unwrap();
9831 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9832
9833 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9834 .expect("its own output is not a change");
9835 let error = repo
9836 .release_review_worktree_checked(904, &checkpoint)
9837 .unwrap_err();
9838
9839 assert!(error.to_string().contains("recoverable"), "{error}");
9840 assert_eq!(
9841 "recover me\n",
9842 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9843 );
9844 repo.release_review_worktree(904);
9845 }
9846
9847 #[test]
9848 fn a_preexisting_ignored_review_file_change_is_retained() {
9854 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9855 std::fs::create_dir_all(path.join("generated")).unwrap();
9856 let ignored = path.join("generated/recovery.txt");
9857 std::fs::write(&ignored, "before\n").unwrap();
9858 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9859 std::fs::write(&ignored, "after!\n").unwrap();
9860
9861 let error = repo
9862 .release_review_worktree_checked(905, &checkpoint)
9863 .unwrap_err();
9864
9865 assert!(error.to_string().contains("recoverable"), "{error}");
9866 assert!(path.exists());
9867 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9868 repo.release_review_worktree(905);
9869 }
9870
9871 #[test]
9872 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9873 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9874 std::fs::create_dir_all(path.join("generated")).unwrap();
9875 let ignored = path.join("generated/recovery.txt");
9876 std::fs::write(&ignored, "keep me\n").unwrap();
9877 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9878
9879 let error = repo
9880 .release_review_worktree_checked(906, &checkpoint)
9881 .unwrap_err();
9882
9883 assert!(error.to_string().contains("recoverable"), "{error}");
9884 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9885 }
9886
9887 #[test]
9888 fn overwriting_a_preexisting_untracked_file_is_detected() {
9889 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9890 let untracked = path.join("notes.txt");
9891 std::fs::write(&untracked, "before\n").unwrap();
9892 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9893 std::fs::write(&untracked, "after!\n").unwrap();
9894
9895 let error = repo
9896 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9897 .unwrap_err();
9898
9899 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9900 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9901 }
9902
9903 #[test]
9904 fn an_assume_unchanged_edit_is_detected() {
9905 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9906 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9907 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9908
9909 let error = repo
9910 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9911 .unwrap_err();
9912
9913 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9914 assert_eq!(
9915 "hidden\n",
9916 std::fs::read_to_string(path.join("README.md")).unwrap()
9917 );
9918 }
9919
9920 #[test]
9921 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9922 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9923 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9924 test_git(&path, &["add", "README.md"]);
9925 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9926
9927 let error = repo
9928 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9929 .unwrap_err();
9930
9931 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9932 assert_eq!(
9933 b"seed\r\n",
9934 std::fs::read(path.join("README.md")).unwrap().as_slice()
9935 );
9936 }
9937
9938 #[cfg(unix)]
9939 #[test]
9940 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9941 use std::os::unix::fs::PermissionsExt;
9942
9943 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9944 test_git(&path, &["config", "core.filemode", "false"]);
9945 let readme = path.join("README.md");
9946 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9947 permissions.set_mode(0o755);
9948 std::fs::set_permissions(&readme, permissions).unwrap();
9949 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9950
9951 let error = repo
9952 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9953 .unwrap_err();
9954
9955 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9956 assert_eq!(
9957 0o755,
9958 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9959 );
9960 }
9961
9962 #[test]
9963 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9964 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9965 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9966 test_git(&path, &["add", ".gitattributes"]);
9967 test_git(&path, &["commit", "-m", "select data filter"]);
9968 let baseline = repo.worktree_baseline(&path).unwrap();
9969 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9970
9971 assert!(repo
9972 .commit_pending_changes(&path, &baseline, "change data", "change data")
9973 .unwrap());
9974 let error = repo
9975 .refuse_unrepresented_tracked_changes(&path, &baseline)
9976 .unwrap_err();
9977
9978 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9979 assert_eq!(
9980 "secret: recover me\nnew\n",
9981 std::fs::read_to_string(path.join("data.txt")).unwrap()
9982 );
9983 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9984 }
9985
9986 #[test]
9987 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9988 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9989 std::fs::create_dir_all(path.join("target")).unwrap();
9990 let untracked = path.join("target/user.yaml");
9991 std::fs::write(&untracked, "user data\n").unwrap();
9992 let baseline = repo.worktree_baseline(&path).unwrap();
9993 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9994
9995 assert!(repo
9996 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9997 .unwrap());
9998
9999 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
10000 assert_eq!(
10001 "?? target/user.yaml\n",
10002 test_git(&path, &["status", "--short", "--untracked-files=all"])
10003 );
10004 assert!(test_git(
10005 &path,
10006 &[
10007 "ls-tree",
10008 "-r",
10009 "--name-only",
10010 "HEAD",
10011 "--",
10012 "target/user.yaml"
10013 ]
10014 )
10015 .is_empty());
10016 }
10017
10018 #[test]
10019 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
10020 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
10021 std::fs::create_dir_all(path.join("target")).unwrap();
10022 let untracked = path.join("target/user.yaml");
10023 std::fs::write(&untracked, "before\n").unwrap();
10024 let baseline = repo.worktree_baseline(&path).unwrap();
10025 let before = test_git(&path, &["rev-parse", "HEAD"]);
10026 std::fs::write(&untracked, "after\n").unwrap();
10027 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10028
10029 let error = repo
10030 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10031 .unwrap_err();
10032
10033 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10034 assert!(error.to_string().contains("target/user.yaml"), "{error}");
10035 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
10036 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
10037 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
10038 }
10039
10040 #[test]
10041 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
10042 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
10043 let baseline = repo.worktree_baseline(&path).unwrap();
10044 std::fs::create_dir_all(path.join("target")).unwrap();
10045 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
10046
10047 assert!(repo
10048 .commit_pending_changes(&path, &baseline, "add file", "add file")
10049 .unwrap());
10050
10051 assert_eq!(
10052 "new file\n",
10053 test_git(&path, &["show", "HEAD:target/new.txt"])
10054 );
10055 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10056 }
10057
10058 #[test]
10059 fn a_managed_commit_accepts_a_custom_output_rebuild() {
10060 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
10061 exclude_paths(&repo, &["manager/public/"]);
10062 let output = path.join("manager/public/assets");
10063 std::fs::create_dir_all(&output).unwrap();
10064 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
10065 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
10066 let baseline = repo.worktree_baseline(&path).unwrap();
10067 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10068 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
10069 std::fs::remove_file(output.join("old.js")).unwrap();
10070 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
10071 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
10072
10073 assert!(repo
10074 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10075 .unwrap());
10076 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10077 repo.refuse_changed_existing_untracked(&path, &baseline)
10078 .unwrap();
10079 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
10080 .unwrap();
10081
10082 assert_eq!(
10083 "tracked change\n",
10084 test_git(&path, &["show", "HEAD:README.md"])
10085 );
10086 assert_eq!(
10087 "new source\n",
10088 test_git(&path, &["show", "HEAD:new-source.txt"])
10089 );
10090 assert!(test_git(
10091 &path,
10092 &[
10093 "ls-tree",
10094 "-r",
10095 "--name-only",
10096 "HEAD",
10097 "--",
10098 "manager/public"
10099 ]
10100 )
10101 .is_empty());
10102 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10103 assert!(!output.join("old.js").exists());
10104 assert_eq!(
10105 "new styles\n",
10106 std::fs::read_to_string(output.join("index.css")).unwrap()
10107 );
10108 assert!(repository_has_recoverable_work(&path, true).unwrap());
10109 repo.release_review_worktree(912);
10110 assert!(output.join("index-DUym-Rfj.js").exists());
10111 }
10112
10113 #[test]
10114 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
10115 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
10116 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
10117 let baseline = repo.worktree_baseline(&path).unwrap();
10118 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
10119 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
10120
10121 let error = repo
10122 .commit_pending_changes(&path, &baseline, "change", "change")
10123 .unwrap_err();
10124
10125 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10126 assert!(error.to_string().contains("notes.txt"), "{error}");
10127 }
10128
10129 #[test]
10130 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
10131 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
10132 let baseline = repo.worktree_baseline(&path).unwrap();
10133 let nested = path.join("generated/repro");
10134 std::fs::create_dir_all(&nested).unwrap();
10135 test_git(&nested, &["init"]);
10136 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10137
10138 assert!(repo
10139 .commit_pending_changes(&path, &baseline, "change", "change")
10140 .unwrap());
10141 assert!(!repo.has_uncommitted_changes(&path).unwrap());
10142 assert!(repository_has_recoverable_work(&path, true).unwrap());
10143 repo.release_review_worktree(927);
10144 assert!(nested.join(".git").exists());
10145 }
10146
10147 #[test]
10148 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
10149 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
10150 test_git(&path, &["config", "core.autocrlf", "true"]);
10151 std::fs::write(path.join("README.md"), "seed\n").unwrap();
10152 assert_eq!(
10153 test_git(&path, &["hash-object", "README.md"]).trim(),
10154 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10155 );
10156
10157 assert!(repository_has_recoverable_work(&path, true).unwrap());
10158 assert_eq!(
10159 "seed\n",
10160 std::fs::read_to_string(path.join("README.md")).unwrap()
10161 );
10162 }
10163
10164 #[test]
10165 fn autocrlf_input_overrides_a_crlf_core_eol() {
10166 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
10167 test_git(&path, &["config", "core.autocrlf", "input"]);
10168 test_git(&path, &["config", "core.eol", "crlf"]);
10169 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
10170 assert_eq!(
10171 test_git(&path, &["hash-object", "README.md"]).trim(),
10172 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10173 );
10174
10175 assert!(repository_has_recoverable_work(&path, true).unwrap());
10176 assert_eq!(
10177 b"seed\r\n",
10178 std::fs::read(path.join("README.md")).unwrap().as_slice()
10179 );
10180 }
10181
10182 #[cfg(unix)]
10183 #[test]
10184 fn a_non_executable_permission_change_is_recoverable() {
10185 use std::os::unix::fs::PermissionsExt;
10186
10187 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
10188 let readme = path.join("README.md");
10189 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
10190 permissions.set_mode(0o600);
10191 std::fs::set_permissions(&readme, permissions).unwrap();
10192 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10193
10194 let error = repo
10195 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10196 .unwrap_err();
10197
10198 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10199 assert!(repository_has_recoverable_work(&path, true).unwrap());
10200 assert_eq!(
10201 0o600,
10202 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
10203 );
10204 }
10205
10206 #[cfg(unix)]
10207 #[test]
10208 fn a_managed_commit_skips_signing_and_hooks() {
10209 use std::os::unix::fs::PermissionsExt;
10210
10211 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
10212 let common = common_git_dir(&path).unwrap();
10213 let hook = common.join("hooks/pre-commit");
10214 let marker = fixture.root.join("hook-ran");
10215 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
10216 std::fs::write(
10217 &hook,
10218 format!(
10219 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
10220 sh_quote(marker.to_str().unwrap())
10221 ),
10222 )
10223 .unwrap();
10224 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
10225 permissions.set_mode(0o755);
10226 std::fs::set_permissions(&hook, permissions).unwrap();
10227 test_git(&path, &["config", "commit.gpgsign", "true"]);
10228 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
10229 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
10230 test_git(&path, &["add", "managed.txt"]);
10231
10232 repo.commit_staged_changes(&path, "record managed change")
10233 .unwrap();
10234
10235 assert!(!marker.exists());
10236 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
10237 }
10238
10239 #[test]
10240 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
10241 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
10242 std::fs::write(
10243 path.join(".gitattributes"),
10244 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
10245 )
10246 .unwrap();
10247 test_git(&path, &["add", ".gitattributes"]);
10248 test_git(&path, &["commit", "-m", "select automatic text"]);
10249 test_git(&path, &["config", "core.autocrlf", "true"]);
10250
10251 assert!(repository_has_recoverable_work(&path, true).unwrap());
10252 }
10253
10254 #[test]
10255 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
10256 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
10257 std::fs::write(
10258 path.join(".gitattributes"),
10259 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
10260 )
10261 .unwrap();
10262 test_git(&path, &["add", ".gitattributes"]);
10263 test_git(&path, &["commit", "-m", "select ident expansion"]);
10264 std::fs::write(path.join("README.md"), "seed\n").unwrap();
10265
10266 assert!(repository_has_recoverable_work(&path, true).unwrap());
10267 }
10268
10269 #[test]
10275 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
10276 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
10277 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
10278 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10279 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
10280 std::fs::write(
10281 path.join("bindings/uniffi-bindgen/Cargo.lock"),
10282 "# generated\n",
10283 )
10284 .unwrap();
10285
10286 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10287 .expect("a path the project ignores is the call's own output");
10288 }
10289
10290 #[test]
10291 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
10292 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
10293 exclude_paths(&repo, &["target/"]);
10294 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10295 std::fs::create_dir_all(path.join("target/package")).unwrap();
10296 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
10297
10298 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10299 .expect("an ignored attribute file governs only ignored paths");
10300 assert!(
10301 !repo.has_uncommitted_changes(&path).unwrap(),
10302 "ignored dependency attributes must not stop the next review"
10303 );
10304 }
10305
10306 #[test]
10307 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
10308 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
10309 std::fs::create_dir_all(path.join("source")).unwrap();
10310 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
10311
10312 assert!(repo.has_uncommitted_changes(&path).unwrap());
10313 }
10314
10315 #[test]
10316 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
10317 for source in ["repository", "nested", "local", "global"] {
10318 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
10319 std::fs::create_dir_all(path.join("manager")).unwrap();
10320 match source {
10321 "repository" => {
10322 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
10323 .unwrap();
10324 test_git(&path, &["add", ".gitignore"]);
10325 test_git(&path, &["commit", "-m", "ignore artifacts"]);
10326 }
10327 "nested" => {
10328 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
10329 test_git(&path, &["add", "manager/.gitignore"]);
10330 test_git(&path, &["commit", "-m", "ignore artifacts"]);
10331 }
10332 "local" => exclude_paths(&repo, &["manager/scratch/"]),
10333 "global" => {
10334 let excludes = repo.root().parent().unwrap().join("global-ignore");
10335 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
10336 test_git(
10337 &path,
10338 &["config", "core.excludesFile", excludes.to_str().unwrap()],
10339 );
10340 }
10341 _ => unreachable!(),
10342 }
10343 let output = path.join("manager/scratch");
10344 std::fs::create_dir_all(&output).unwrap();
10345 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
10346 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
10347 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10348 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
10349 std::fs::remove_file(output.join(".DS_Store")).unwrap();
10350 for file in [
10351 ".gitattributes",
10352 "Cargo.lock",
10353 "font.woff2",
10354 "bundle.js",
10355 "cache.db",
10356 ] {
10357 std::fs::write(output.join(file), "generated\n").unwrap();
10358 }
10359 #[cfg(unix)]
10360 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
10361 let nested = output.join("dependency");
10362 std::fs::create_dir_all(&nested).unwrap();
10363 test_git(&nested, &["init"]);
10364 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10365 .unwrap_or_else(|error| panic!("{source}: {error}"));
10366 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10367
10368 let baseline = repo.worktree_baseline(&path).unwrap();
10369 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
10370 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
10371 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
10372 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
10373 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
10374 assert!(repo
10375 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
10376 .unwrap_or_else(|error| panic!("{source}: {error}")));
10377 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10378 assert!(test_git(
10379 &path,
10380 &[
10381 "ls-tree",
10382 "-r",
10383 "--name-only",
10384 "HEAD",
10385 "--",
10386 "manager/scratch"
10387 ]
10388 )
10389 .is_empty());
10390 assert!(
10391 repository_has_recoverable_work(&path, true).unwrap(),
10392 "{source}"
10393 );
10394 repo.release_review_worktree(945);
10395 assert_eq!(
10396 "LOCAL=updated\n",
10397 std::fs::read_to_string(output.join(".env.local")).unwrap()
10398 );
10399 assert!(nested.join(".git").exists(), "{source}");
10400 }
10401 }
10402
10403 #[test]
10407 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
10408 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
10409 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10410 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
10411
10412 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10413 .expect("spar's own marker is not something a call did");
10414 }
10415
10416 #[test]
10417 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
10418 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
10419
10420 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
10421 .expect("a path a build removed is not an unreadable worktree");
10422
10423 assert_eq!(vanished_untracked_file(), gone);
10424 }
10425
10426 #[test]
10429 fn a_vanished_path_is_not_recorded_as_absent() {
10430 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
10431 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10432 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10433 std::fs::remove_file(path.join("notes.txt")).unwrap();
10434
10435 let error = repo
10436 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10437 .unwrap_err();
10438
10439 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10440 }
10441
10442 fn exclude_paths(repo: &Repo, lines: &[&str]) {
10443 use std::io::Write;
10444 let path = repo.root().join(".git").join("info").join("exclude");
10445 let mut file = std::fs::OpenOptions::new()
10446 .create(true)
10447 .append(true)
10448 .open(&path)
10449 .unwrap();
10450 for line in lines {
10451 writeln!(file, "{line}").unwrap();
10452 }
10453 }
10454
10455 #[test]
10456 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
10457 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
10458 exclude_paths(&repo, &["dist/"]);
10459 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10460 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10461 let baseline = repo.worktree_baseline(&path).unwrap();
10462 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10463 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10464 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10465 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10466
10467 assert!(repo
10468 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10469 .unwrap());
10470 assert_eq!(
10471 "tracked change\n",
10472 std::fs::read_to_string(path.join("README.md")).unwrap()
10473 );
10474 }
10475
10476 #[test]
10477 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10478 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10479 exclude_paths(&repo, &["dist/"]);
10480 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10481 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10482 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10483 let baseline = repo.worktree_baseline(&path).unwrap();
10484 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10485 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10486 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10487 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10488
10489 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10490 repo.refuse_changed_existing_untracked(&path, &baseline)
10491 .unwrap();
10492 }
10493
10494 #[test]
10495 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10496 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10497 exclude_paths(&repo, &["dist/"]);
10498 std::fs::create_dir_all(path.join("dist")).unwrap();
10499 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10500 let baseline = repo.worktree_baseline(&path).unwrap();
10501 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10502 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10503
10504 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10505 repo.refuse_changed_existing_untracked(&path, &baseline)
10506 .unwrap();
10507 }
10508
10509 #[test]
10518 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10519 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10520 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10521 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10522 test_git(
10523 repo.root(),
10524 &[
10525 "worktree",
10526 "add",
10527 "-b",
10528 "issue-950",
10529 held.to_str().unwrap(),
10530 "main",
10531 ],
10532 );
10533
10534 let found = repo
10535 .worktree_holding("issue-950")
10536 .expect("the branch is checked out somewhere");
10537
10538 assert!(found.ends_with("issue-950"), "{}", found.display());
10539 assert_eq!(None, repo.worktree_holding("issue-951"));
10540 }
10541
10542 #[test]
10543 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10544 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10545 exclude_paths(&repo, &["manager/public/assets/"]);
10546 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10547 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10548 std::fs::write(&bundle, "first build\n").unwrap();
10549 let baseline = repo.worktree_baseline(&path).unwrap();
10550 std::fs::write(&bundle, "second build\n").unwrap();
10551 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10552
10553 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10554 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10555 }
10556
10557 #[test]
10560 fn a_file_a_reviewer_created_is_moved_aside_and_the_checkout_is_whole_again() {
10561 let (_fixture, repo, path, checkpoint) = review_fixture("scratch-aside", 945);
10562 std::fs::create_dir_all(path.join("tests")).unwrap();
10563 std::fs::write(path.join("tests/zz-repro.test.ts"), "repro\n").unwrap();
10564
10565 repo.set_aside_review_scratch(&path, &checkpoint, 945);
10566
10567 assert!(!path.join("tests/zz-repro.test.ts").exists());
10568 let shelves: Vec<PathBuf> = std::fs::read_dir(repo.root().join(".spar/scratch"))
10569 .unwrap()
10570 .flatten()
10571 .map(|entry| entry.path())
10572 .collect();
10573 assert_eq!(1, shelves.len(), "{shelves:?}");
10574 assert_eq!(
10575 "repro\n",
10576 std::fs::read_to_string(shelves[0].join("tests/zz-repro.test.ts")).unwrap()
10577 );
10578 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10579 .unwrap();
10580 }
10581
10582 #[test]
10586 fn only_a_file_that_was_not_there_is_moved_aside() {
10587 let (_fixture, repo, path, _checkpoint) = review_fixture("scratch-only-new", 946);
10588 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10589 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10590 std::fs::write(path.join("README.md"), "rewritten\n").unwrap();
10591 std::fs::write(path.join("notes.txt"), "after\n").unwrap();
10592
10593 repo.set_aside_review_scratch(&path, &checkpoint, 946);
10594
10595 assert_eq!(
10596 "rewritten\n",
10597 std::fs::read_to_string(path.join("README.md")).unwrap()
10598 );
10599 assert_eq!(
10600 "after\n",
10601 std::fs::read_to_string(path.join("notes.txt")).unwrap()
10602 );
10603 assert!(!repo.root().join(".spar/scratch").exists());
10604 let error = repo
10605 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10606 .unwrap_err();
10607 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10608 assert!(error.to_string().contains("README.md"), "{error}");
10609 assert!(error.to_string().contains("notes.txt"), "{error}");
10610 }
10611
10612 #[test]
10613 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10614 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10615 exclude_paths(&repo, &["dist/"]);
10616 let baseline = repo.worktree_baseline(&path).unwrap();
10617 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10618
10619 let error = repo
10620 .refuse_read_only_leavings(&path, &baseline)
10621 .unwrap_err();
10622
10623 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10624 assert!(error.to_string().contains("scratch.md"), "{error}");
10625 }
10626
10627 #[test]
10628 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10629 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10630 exclude_paths(&repo, &["dist/", ".env.local"]);
10631 std::fs::create_dir_all(path.join("dist")).unwrap();
10632 let baseline = repo.worktree_baseline(&path).unwrap();
10633 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10634 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10635
10636 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10637 assert!(repository_has_recoverable_work(&path, true).unwrap());
10638 }
10639
10640 #[test]
10641 fn a_read_only_inspection_may_rebuild_generated_output() {
10642 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10643 exclude_paths(&repo, &["dist/"]);
10644 std::fs::create_dir_all(path.join("dist")).unwrap();
10645 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10646 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10647 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10648 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10649
10650 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10651 .unwrap();
10652 }
10653
10654 #[test]
10655 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10656 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10657 exclude_paths(&repo, &["dist/", ".env.local"]);
10658 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10659 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10660 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10661
10662 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10663 .unwrap();
10664 }
10665
10666 #[test]
10667 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10668 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10669 exclude_paths(&repo, &["dist/", ".env.local"]);
10670 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10671 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10672 std::fs::remove_file(path.join(".env.local")).unwrap();
10673
10674 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10675 .unwrap();
10676 }
10677
10678 #[test]
10679 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10680 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10681 exclude_paths(&repo, &["dist/"]);
10682 std::fs::write(path.join("notes.md"), "before\n").unwrap();
10683 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10684 std::fs::write(path.join("notes.md"), "after\n").unwrap();
10685
10686 let error = repo
10687 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10688 .unwrap_err();
10689
10690 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10691 assert!(error.to_string().contains("notes.md"), "{error}");
10692 }
10693
10694 #[test]
10695 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10696 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10697 exclude_paths(&repo, &["dist/", "local.env"]);
10698 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10699 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10700 std::fs::write(
10703 repo.root().join(".git").join("info").join("exclude"),
10704 "dist/\n",
10705 )
10706 .unwrap();
10707
10708 let error = repo
10709 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10710 .unwrap_err();
10711
10712 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10713 assert!(error.to_string().contains("local.env"), "{error}");
10714 }
10715
10716 #[test]
10717 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10718 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10719 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10720 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10721
10722 let error = repo
10723 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10724 .unwrap_err();
10725
10726 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10727 assert!(error.to_string().contains("README.md"), "{error}");
10728 }
10729
10730 #[test]
10731 fn build_output_alone_does_not_keep_a_worktree() {
10732 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10733 exclude_paths(&repo, &["target/", "dist/"]);
10734 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10735 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10736 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10737 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10738
10739 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10740 repo.release_review_worktree(933);
10741
10742 assert!(!path.exists());
10743 }
10744
10745 #[test]
10746 fn finder_metadata_alone_does_not_keep_a_worktree() {
10747 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10748 exclude_paths(&repo, &[".DS_Store"]);
10749 std::fs::create_dir_all(path.join("src")).unwrap();
10750 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10751 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10752
10753 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10754 repo.release_review_worktree(937);
10755
10756 assert!(!path.exists());
10757 }
10758
10759 #[test]
10760 fn a_fetch_record_alone_does_not_keep_a_worktree() {
10761 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10762 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10763 let git_dir = if raw.is_absolute() {
10764 raw
10765 } else {
10766 path.join(raw)
10767 };
10768 let head = test_git(&path, &["rev-parse", "HEAD"]);
10769 std::fs::write(
10770 git_dir.join("FETCH_HEAD"),
10771 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10772 )
10773 .unwrap();
10774
10775 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10776 repo.release_review_worktree(938);
10777
10778 assert!(!path.exists());
10779 }
10780
10781 #[test]
10782 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10783 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10784 exclude_paths(&repo, &["target/", ".env.local"]);
10785 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10786 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10787 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10788
10789 assert!(repository_has_recoverable_work(&path, true).unwrap());
10790 repo.release_review_worktree(934);
10791
10792 assert_eq!(
10793 "TOKEN=keep me\n",
10794 std::fs::read_to_string(path.join(".env.local")).unwrap()
10795 );
10796 }
10797
10798 #[test]
10799 fn a_repository_nested_in_build_output_keeps_a_worktree() {
10800 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10801 exclude_paths(&repo, &["node_modules/"]);
10802 let nested = path.join("node_modules/local-dep");
10803 std::fs::create_dir_all(&nested).unwrap();
10804 test_git(&nested, &["init"]);
10805 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10806
10807 assert!(repository_has_recoverable_work(&path, true).unwrap());
10808 repo.release_review_worktree(935);
10809
10810 assert!(nested.join(".git").exists());
10811 }
10812
10813 #[test]
10814 fn an_ordinary_untracked_file_keeps_a_worktree() {
10815 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10816 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10817
10818 assert!(repository_has_recoverable_work(&path, true).unwrap());
10819 repo.release_review_worktree(936);
10820
10821 assert_eq!(
10822 "somebody's notes\n",
10823 std::fs::read_to_string(path.join("notes.md")).unwrap()
10824 );
10825 }
10826
10827 #[test]
10828 fn a_legacy_crlf_checkout_is_retained_conservatively() {
10829 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10830 std::fs::write(
10831 path.join(".gitattributes"),
10832 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10833 )
10834 .unwrap();
10835 test_git(&path, &["add", ".gitattributes"]);
10836 test_git(&path, &["commit", "-m", "select legacy line endings"]);
10837
10838 assert!(repository_has_recoverable_work(&path, true).unwrap());
10839 }
10840
10841 #[test]
10842 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10843 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10844 let nested = path.join("tracked");
10845 std::fs::create_dir_all(&nested).unwrap();
10846 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10847 test_git(&path, &["add", "tracked/seed.txt"]);
10848 test_git(&path, &["commit", "-m", "add tracked directory"]);
10849 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10850 test_git(&nested, &["init"]);
10851
10852 let error = repo
10853 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10854 .unwrap_err();
10855
10856 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10857 assert!(error.to_string().contains("Git entry"), "{error}");
10858 assert!(nested.join(".git").exists());
10859 }
10860
10861 #[test]
10866 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10867 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10868 exclude_paths(&repo, &["target/"]);
10869 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10870 let repro = path.join("target/pr918-repro");
10871 std::fs::create_dir_all(&repro).unwrap();
10872 test_git(&repro, &["init"]);
10873 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10874
10875 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10876 .unwrap();
10877
10878 assert!(repository_has_recoverable_work(&path, true).unwrap());
10879 assert!(repro.join(".git").exists());
10880 }
10881
10882 #[test]
10886 fn a_repository_inside_a_nested_checkout_is_left_alone() {
10887 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10888 exclude_paths(&repo, &["target/"]);
10889 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10890 let repro = path.join("target/repro");
10891 let vendored = repro.join("vendor/dep");
10892 std::fs::create_dir_all(&vendored).unwrap();
10893 test_git(&repro, &["init"]);
10894 test_git(&vendored, &["init"]);
10895
10896 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10897 .unwrap();
10898
10899 assert!(vendored.join(".git").exists());
10900 }
10901
10902 #[test]
10903 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10904 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10905
10906 let state = ignored_untracked_state(repo.root()).unwrap();
10907
10908 let relative = path.strip_prefix(repo.root()).unwrap();
10909 assert!(
10910 state.files.contains_key(relative),
10911 "{:?}",
10912 state.files.keys().collect::<Vec<_>>()
10913 );
10914 assert!(state.is_ignored(relative));
10915 }
10916
10917 #[test]
10918 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10919 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10920 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10921 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10922 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10923
10924 repo.refuse_new_ignored_files(repo.root(), &baseline)
10925 .unwrap();
10926 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10927 .unwrap();
10928 }
10929
10930 #[test]
10931 fn deleting_a_resident_worktree_during_a_call_is_refused() {
10932 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10933 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10934 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10935 std::fs::remove_dir_all(&path).unwrap();
10936
10937 let read_error = repo
10938 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10939 .unwrap_err();
10940 assert!(
10941 read_error.to_string().contains("review-932"),
10942 "{read_error}"
10943 );
10944 let error = repo
10945 .refuse_new_ignored_files(repo.root(), &baseline)
10946 .unwrap_err();
10947
10948 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10949 assert!(error.to_string().contains("review-932"), "{error}");
10950 }
10951
10952 #[test]
10953 fn a_nested_repository_record_is_read_as_a_plain_path() {
10954 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10955 assert_eq!(Path::new("vendor/checkout"), path);
10956 assert!(nested);
10957
10958 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10959 assert_eq!(Path::new("vendor/notes.txt"), path);
10960 assert!(!nested);
10961
10962 assert!(untracked_record(b"/", "untracked").is_err());
10963 }
10964
10965 #[cfg(unix)]
10966 #[test]
10967 fn a_non_utf8_git_path_is_preserved_without_loss() {
10968 use std::os::unix::ffi::OsStrExt;
10969
10970 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10971
10972 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10973 }
10974
10975 #[test]
10976 fn guarded_merge_pins_the_reviewed_head() {
10977 let args = merge_pr_args("36", Some("abc123"), true);
10978 assert_eq!(
10979 vec![
10980 "pr",
10981 "merge",
10982 "36",
10983 "--squash",
10984 "--delete-branch",
10985 "--match-head-commit",
10986 "abc123"
10987 ],
10988 args
10989 );
10990 }
10991
10992 #[test]
10993 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10994 let pr = PrRef {
10995 number: 7,
10996 url: "https://example.test/pull/7".into(),
10997 title: "part one".into(),
10998 };
10999 let result = reconcile_pr_creation(
11000 "split-34-1",
11001 Err(crate::error::SparError::new("connection lost")),
11002 Ok(Some(pr)),
11003 )
11004 .unwrap();
11005 assert_eq!(7, result.number);
11006 }
11007
11008 #[test]
11009 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
11010 let error = reconcile_pr_creation(
11011 "split-34-1",
11012 Err(crate::error::SparError::new("permission denied")),
11013 Ok(None),
11014 )
11015 .unwrap_err();
11016 assert!(error.to_string().contains("permission denied"), "{error}");
11017 }
11018
11019 #[test]
11020 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
11021 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
11022 assert!(pr_for_base(text, "split-34-2", "split-34-1")
11023 .unwrap()
11024 .is_none());
11025 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
11026 assert_eq!(7, found.number);
11027 }
11028
11029 #[test]
11030 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
11031 let result = reconcile_comment_post(
11032 34,
11033 "the summary",
11034 crate::error::SparError::new("connection lost"),
11035 Ok(vec![serde_json::json!({"body": "the summary"})]),
11036 );
11037 assert!(result.is_ok(), "{result:?}");
11038 }
11039
11040 #[test]
11041 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
11042 let error = reconcile_comment_post(
11043 34,
11044 "the summary",
11045 crate::error::SparError::new("connection lost"),
11046 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
11047 )
11048 .unwrap_err();
11049 assert_eq!("connection lost", error.to_string());
11050 }
11051
11052 #[test]
11053 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
11054 let error = reconcile_comment_post(
11055 34,
11056 "the summary",
11057 crate::error::SparError::new("connection lost"),
11058 Err(crate::error::SparError::new("comments unavailable")),
11059 )
11060 .unwrap_err();
11061 assert!(
11062 error.to_string().contains("could not be verified"),
11063 "{error}"
11064 );
11065 assert!(
11066 error.to_string().contains("comments unavailable"),
11067 "{error}"
11068 );
11069 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11070 assert!(!error.worth_retrying());
11071 }
11072
11073 #[test]
11074 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
11075 let result = reconcile_issue_edit(
11076 34,
11077 "wanted body",
11078 crate::error::SparError::new("connection lost"),
11079 Ok("wanted body".to_string()),
11080 );
11081 assert!(result.is_ok(), "{result:?}");
11082 }
11083
11084 #[test]
11085 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
11086 let error = reconcile_issue_edit(
11087 34,
11088 "wanted body",
11089 crate::error::SparError::new("connection lost"),
11090 Err(crate::error::SparError::new("issue unavailable")),
11091 )
11092 .unwrap_err();
11093 assert!(
11094 error.to_string().contains("could not be verified"),
11095 "{error}"
11096 );
11097 assert!(error.to_string().contains("issue unavailable"), "{error}");
11098 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11099 assert!(!error.worth_retrying());
11100 }
11101
11102 #[test]
11103 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
11104 let found = ExistingIssue {
11105 number: 101,
11106 url: "https://example.test/issues/101".into(),
11107 title: "child".into(),
11108 body: "body".into(),
11109 open: true,
11110 };
11111 let url = reconcile_issue_creation(
11112 "child",
11113 Err(crate::error::SparError::new("connection lost")),
11114 Ok(Some(found)),
11115 )
11116 .unwrap();
11117 assert_eq!("https://example.test/issues/101", url);
11118 }
11119
11120 #[test]
11121 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
11122 let error = reconcile_issue_creation(
11123 "child",
11124 Err(crate::error::SparError::new("permission denied")),
11125 Ok(None),
11126 )
11127 .unwrap_err();
11128 assert!(error.to_string().contains("permission denied"), "{error}");
11129 }
11130
11131 #[test]
11132 fn an_unverifiable_issue_creation_is_marked_uncertain() {
11133 let error = reconcile_issue_creation(
11134 "child",
11135 Err(crate::error::SparError::new("connection lost")),
11136 Err(crate::error::SparError::new("issues unavailable")),
11137 )
11138 .unwrap_err();
11139 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11140 assert!(!error.worth_retrying());
11141 }
11142
11143 #[test]
11144 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
11145 let result = reconcile_failed_split_push(
11146 "split-34-1",
11147 crate::error::SparError::new("connection lost"),
11148 Ok("abc123\n".into()),
11149 Ok("abc123\trefs/heads/split-34-1\n".into()),
11150 );
11151 assert!(result.is_ok(), "{result:?}");
11152 }
11153
11154 #[test]
11155 fn a_split_push_collision_is_definite_and_never_overwrites() {
11156 let error = reconcile_failed_split_push(
11157 "split-34-1",
11158 crate::error::SparError::new("lease rejected"),
11159 Ok("abc123\n".into()),
11160 Ok("def456\trefs/heads/split-34-1\n".into()),
11161 )
11162 .unwrap_err();
11163 assert!(!error.retain_worktree());
11164 assert!(
11165 error.to_string().contains("Nothing was overwritten"),
11166 "{error}"
11167 );
11168 }
11169
11170 #[test]
11171 fn an_unreadable_split_push_result_keeps_the_worktree() {
11172 let error = reconcile_failed_split_push(
11173 "split-34-1",
11174 crate::error::SparError::new("connection lost"),
11175 Ok("abc123\n".into()),
11176 Err(crate::error::SparError::new("origin unavailable")),
11177 )
11178 .unwrap_err();
11179 assert!(error.retain_worktree());
11180 assert!(error.to_string().contains("could not confirm"), "{error}");
11181 }
11182
11183 #[test]
11187 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
11188 let repo = repo_for_titles();
11189 for raw in [
11190 "Retry loop spins \u{2014} Retry-After parses to zero",
11191 "plain title",
11192 " spread over\nlines ",
11193 "\u{1F916} Generated with something",
11194 &format!("a \u{2014} {}", "very long title ".repeat(20)),
11195 &"x".repeat(300),
11196 &format!("{} \u{2014} end", "y".repeat(88)),
11197 &{
11202 let tail = "a\u{2014}b c\u{2014}d";
11203 let pad = Style::default().max_title_chars - tail.chars().count();
11204 format!("{}{tail}", "w".repeat(pad))
11205 },
11206 ] {
11207 let once = repo.clean_title(raw).unwrap();
11208 let twice = repo.clean_title(&once).unwrap();
11209 assert_eq!(once, twice, "not idempotent for {raw:?}");
11210 assert!(
11211 once.chars().count() <= repo.style.max_title_chars,
11212 "over budget: {once:?}"
11213 );
11214 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
11215 }
11216 }
11217
11218 #[test]
11219 fn a_title_with_an_em_dash_survives_as_readable_text() {
11220 let repo = repo_for_titles();
11221 assert_eq!(
11222 "Retry loop spins, Retry-After parses to zero",
11223 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
11224 .unwrap()
11225 );
11226 }
11227
11228 #[test]
11229 fn sh_quote_survives_a_quote() {
11230 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
11231 }
11232
11233 #[test]
11234 fn sh_quote_wraps_a_space() {
11235 assert_eq!(
11236 "'/Applications/My App/spar'",
11237 sh_quote("/Applications/My App/spar")
11238 );
11239 }
11240
11241 #[test]
11242 fn finished_states_are_recognised_case_insensitively() {
11243 assert!(is_finished("MERGED"));
11244 assert!(is_finished("closed"));
11245 assert!(!is_finished("OPEN"));
11246 assert!(!is_finished(""));
11247 }
11248
11249 fn state() -> PersistedState {
11250 PersistedState {
11251 version: 1,
11252 checkpoint: 0,
11253 round: 4,
11254 next_actor: "codex".into(),
11255 status: Status::Pending,
11256 pr_head: "abc123".into(),
11257 ledger: Ledger::new(),
11258 filed: vec![],
11259 open_findings: vec![Finding {
11260 severity: Severity::Blocking,
11261 title: "Unchecked error".into(),
11262 detail: "the failure is discarded".into(),
11263 file: "src/a.rs:12".into(),
11264 ..Finding::default()
11265 }],
11266 disputes: vec![Dispute {
11267 title: "Retry limit".into(),
11268 file: "src/net.rs".into(),
11269 reasoning: "the caller already bounds it".into(),
11270 }],
11271 noted: vec![Finding {
11272 severity: Severity::NonBlocking,
11273 title: "Timeout is fixed".into(),
11274 file: "src/config.rs".into(),
11275 ..Finding::default()
11276 }],
11277 }
11278 }
11279
11280 #[test]
11281 fn a_state_comment_round_trips() {
11282 let body = format!(
11283 "{STATE_MARKER}\n{}\n-->",
11284 serde_json::to_string(&state()).unwrap()
11285 );
11286 let back = parse_state_comment(&body).unwrap();
11287 assert_eq!(4, back.round);
11288 assert_eq!("codex", back.next_actor);
11289 assert_eq!("abc123", back.pr_head);
11290 assert_eq!("Unchecked error", back.open_findings[0].title);
11291 assert_eq!("src/net.rs", back.disputes[0].file);
11292 assert_eq!("Timeout is fixed", back.noted[0].title);
11293 }
11294
11295 #[test]
11296 fn old_state_without_new_lists_still_parses() {
11297 let body = format!(
11298 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
11299 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11300 );
11301 let back = parse_state_comment(&body).expect("old state");
11302 assert!(back.open_findings.is_empty());
11303 assert!(back.disputes.is_empty());
11304 assert!(back.noted.is_empty());
11305 assert!(back.pr_head.is_empty());
11306 assert_eq!(0, back.checkpoint);
11307 }
11308
11309 #[test]
11310 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
11311 let mut local = state();
11312 local.pr_head = "old".into();
11313 local.round = 9;
11314 let mut remote = state();
11315 remote.pr_head = "current".into();
11316 remote.round = 4;
11317
11318 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11319 assert_eq!("current", chosen.pr_head);
11320 assert_eq!(4, chosen.round);
11321 }
11322
11323 #[test]
11324 fn checkpoint_order_breaks_same_round_ties() {
11325 let mut local = state();
11326 local.pr_head = "current".into();
11327 local.round = 4;
11328 local.checkpoint = 8;
11329 let mut remote = local.clone();
11330 remote.checkpoint = 7;
11331 remote.open_findings.clear();
11332
11333 let chosen = choose_state_for_head(vec![local], "current").unwrap();
11334 assert_eq!(8, chosen.checkpoint);
11335
11336 let mut local = state();
11337 local.pr_head = "current".into();
11338 local.round = 4;
11339 local.checkpoint = 8;
11340 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
11341 assert_eq!(8, chosen.checkpoint);
11342 }
11343
11344 #[test]
11345 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
11346 let mut local = state();
11347 local.pr_head = "current".into();
11348 local.round = 4;
11349 local.open_findings.push(Finding {
11350 title: "local checkpoint".into(),
11351 ..Finding::default()
11352 });
11353 let mut remote = state();
11354 remote.pr_head = "current".into();
11355 remote.round = 4;
11356
11357 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11358 assert_eq!(
11359 "local checkpoint",
11360 chosen.open_findings.last().unwrap().title
11361 );
11362 }
11363
11364 #[test]
11366 fn the_state_block_is_an_html_comment() {
11367 let body = format!(
11368 "{STATE_MARKER}\n{}\n-->",
11369 serde_json::to_string(&state()).unwrap()
11370 );
11371 assert!(body.starts_with("<!--"));
11372 assert!(body.trim_end().ends_with("-->"));
11373 assert!(!body[..body.find('{').unwrap()].contains("-->"));
11374 }
11375
11376 #[test]
11377 fn an_unrelated_json_block_is_not_state() {
11378 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
11379 }
11380
11381 #[test]
11388 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
11389 let block = format!(
11390 "{STATE_MARKER}\n{}\n-->",
11391 serde_json::to_string(&state()).unwrap()
11392 );
11393 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
11394 assert!(
11395 is_state_comment(&block, "Spar-Bot", "spar-bot"),
11396 "a login differs only in case"
11397 );
11398
11399 assert!(
11400 !is_state_comment(
11401 "why is there a `<!-- spar:state` block on this PR?",
11402 "maintainer",
11403 "spar-bot"
11404 ),
11405 "a question about the block was treated as the block"
11406 );
11407 assert!(
11408 !is_state_comment(&block, "maintainer", "spar-bot"),
11409 "somebody else's comment is not spar's to rewrite"
11410 );
11411 assert!(
11412 !is_state_comment(
11413 &format!("here is what spar writes:\n\n{block}"),
11414 "spar-bot",
11415 "spar-bot"
11416 ),
11417 "the marker has to open the body, not appear in it"
11418 );
11419 assert!(
11420 !is_state_comment(
11421 &format!("{STATE_MARKER}\n{{not json\n-->"),
11422 "spar-bot",
11423 "spar-bot"
11424 ),
11425 "a block that does not parse is not state"
11426 );
11427 assert!(
11428 !is_state_comment(&block, "spar-bot", ""),
11429 "an unknown viewer proves nothing about who wrote it"
11430 );
11431 }
11432
11433 #[test]
11434 fn a_malformed_state_comment_is_none_not_a_panic() {
11435 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
11436 }
11437
11438 #[test]
11439 fn atomic_write_leaves_no_temp_file() {
11440 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
11441 let _ = std::fs::remove_dir_all(&dir);
11442 let path = dir.join("state").join("pr-7.json");
11443 write_json_atomic(&path, &state()).unwrap();
11444 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
11445 .unwrap()
11446 .flatten()
11447 .filter_map(|e| e.file_name().to_str().map(str::to_string))
11448 .collect();
11449 assert_eq!(vec!["pr-7.json".to_string()], files);
11450 let _ = std::fs::remove_dir_all(&dir);
11451 }
11452
11453 #[test]
11454 fn atomic_write_overwrites_rather_than_accumulating() {
11455 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
11456 let _ = std::fs::remove_dir_all(&dir);
11457 let path = dir.join("pr-7.json");
11458 for round in 1..4 {
11459 let mut s = state();
11460 s.round = round;
11461 write_json_atomic(&path, &s).unwrap();
11462 }
11463 let back: PersistedState =
11464 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11465 assert_eq!(3, back.round);
11466 let _ = std::fs::remove_dir_all(&dir);
11467 }
11468
11469 #[test]
11470 fn style_from_env_defaults_to_enforcing() {
11471 std::env::remove_var("SPAR_BAN_EM_DASH");
11472 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
11473 let style = style_from_env();
11474 assert!(style.ban_em_dash && style.ban_ai_attribution);
11475 assert!(
11476 !style.terse,
11477 "the commit filter must not truncate a commit message"
11478 );
11479 }
11480
11481 #[test]
11486 fn a_push_denial_is_read_from_either_transport() {
11487 let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
11488 fatal: Could not read from remote repository.\n\n\
11489 Please make sure you have the correct access rights\n\
11490 and the repository exists.";
11491 let https = "remote: Permission to example/project.git denied to someone.\n\
11492 fatal: unable to access 'https://github.com/example/project.git/': The \
11493 requested URL returned error: 403";
11494 let denied = Some("Permission to example/project.git denied to someone.");
11495 assert_eq!(denied, push_denial(ssh));
11496 assert_eq!(denied, push_denial(https));
11497
11498 let stale = "To github.com:example/project.git\n \
11499 ! [rejected] HEAD -> issue-7 (stale info)\n\
11500 error: failed to push some refs to 'github.com:example/project.git'";
11501 assert_eq!(None, push_denial(stale));
11502
11503 let err = push_error(
11504 &PushTarget::Origin,
11505 "issue-7",
11506 &crate::error::SparError::new(ssh),
11507 );
11508 assert_eq!(
11509 "could not push to origin/issue-7. Permission to example/project.git denied to \
11510 someone.",
11511 err.to_string()
11512 );
11513 }
11514
11515 #[cfg(unix)]
11519 fn denied_fixture(
11520 tag: &str,
11521 fork_when_denied: bool,
11522 ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11523 use std::os::unix::fs::PermissionsExt;
11524 use std::sync::atomic::{AtomicU32, Ordering};
11525 static NEXT: AtomicU32 = AtomicU32::new(0);
11526 let id = NEXT.fetch_add(1, Ordering::Relaxed);
11527 let root =
11528 std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11529 let origin = root.join("origin.git");
11530 let fork = root.join("fork.git");
11531 let work = root.join("work");
11532 for dir in [&origin, &fork, &work] {
11533 std::fs::create_dir_all(dir).unwrap();
11534 }
11535 test_git(&origin, &["init", "--bare", "-b", "main"]);
11536 test_git(&fork, &["init", "--bare", "-b", "main"]);
11537 test_git(&work, &["init", "-b", "main"]);
11538 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11539 test_git(&work, &["config", "user.name", "spar test"]);
11540 test_git(&work, &["config", "commit.gpgsign", "false"]);
11541 std::fs::write(work.join("README.md"), "seed\n").unwrap();
11542 test_git(&work, &["add", "."]);
11543 test_git(&work, &["commit", "-m", "seed"]);
11544 test_git(
11545 &work,
11546 &["remote", "add", "origin", origin.to_str().unwrap()],
11547 );
11548 test_git(&work, &["push", "-q", "origin", "main"]);
11549 let hook = origin.join("hooks").join("pre-receive");
11551 std::fs::write(
11552 &hook,
11553 "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11554 )
11555 .unwrap();
11556 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11557 test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11558 std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11559 test_git(&work, &["add", "."]);
11560 test_git(&work, &["commit", "-m", "Fix it"]);
11561
11562 let mut text =
11563 String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11564 if !fork_when_denied {
11565 text.push_str("[loop]\nfork_when_denied = false\n");
11566 }
11567 let cfg = crate::config::parse(&text).unwrap();
11568 let repo = Repo::open(&work, &cfg).unwrap();
11569 repo.fork
11571 .set(Fork {
11572 owner: "someone".into(),
11573 full_name: "someone/project".into(),
11574 url: fork.to_str().unwrap().into(),
11575 })
11576 .unwrap();
11577 (ReviewFixture { root }, repo, work, origin, fork)
11578 }
11579
11580 #[cfg(unix)]
11581 #[test]
11582 fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11583 let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11584 let first = test_git(&work, &["rev-parse", "HEAD"]);
11585
11586 let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11587
11588 assert_eq!("someone:issue-7", target.head("issue-7"));
11589 assert_eq!(
11590 first.trim(),
11591 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11592 );
11593 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11594 .trim()
11595 .is_empty());
11596 assert_eq!(
11597 WriteSummary {
11598 attempted: 1,
11599 failed: 0
11600 },
11601 repo.write_summary(),
11602 "a refusal the fork answered is not a failed write"
11603 );
11604
11605 std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11608 test_git(&work, &["commit", "-qam", "Fix it again"]);
11609 repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11610 .unwrap();
11611 let second = test_git(&work, &["rev-parse", "HEAD"]);
11612 assert_eq!(
11613 second.trim(),
11614 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11615 );
11616 }
11617
11618 #[cfg(unix)]
11622 #[test]
11623 fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11624 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11625 test_git(
11626 &work,
11627 &[
11628 "push",
11629 "-q",
11630 fork.to_str().unwrap(),
11631 "main:refs/heads/issue-7",
11632 ],
11633 );
11634 let head = test_git(&work, &["rev-parse", "HEAD"]);
11635
11636 repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11637
11638 assert_eq!(
11639 head.trim(),
11640 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11641 );
11642 assert_eq!(0, repo.write_summary().failed);
11643 }
11644
11645 #[cfg(unix)]
11646 #[test]
11647 fn work_only_the_fork_has_is_not_published_over() {
11648 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11649 test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11650 std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11651 test_git(&work, &["add", "."]);
11652 test_git(&work, &["commit", "-qm", "Somebody's work"]);
11653 test_git(
11654 &work,
11655 &[
11656 "push",
11657 "-q",
11658 fork.to_str().unwrap(),
11659 "elsewhere:refs/heads/issue-7",
11660 ],
11661 );
11662 let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11663 test_git(&work, &["checkout", "-q", "issue-7"]);
11664
11665 let err = repo
11666 .publish_new_branch(&work, "issue-7", "main")
11667 .unwrap_err()
11668 .to_string();
11669
11670 assert!(
11671 err.contains(
11672 "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11673 ),
11674 "{err}"
11675 );
11676 assert!(err.contains("--delete issue-7"), "{err}");
11677 assert_eq!(
11678 theirs.trim(),
11679 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11680 );
11681 assert_eq!(
11682 WriteSummary {
11683 attempted: 1,
11684 failed: 1
11685 },
11686 repo.write_summary()
11687 );
11688 }
11689
11690 #[test]
11692 fn a_rejected_push_names_the_ref_it_rejected() {
11693 let stale = "To github.com:example/project.git\n \
11694 ! [rejected] HEAD -> issue-7 (stale info)\n\
11695 error: failed to push some refs to 'github.com:example/project.git'";
11696
11697 let err = push_error(
11698 &PushTarget::Origin,
11699 "issue-7",
11700 &crate::error::SparError::new(stale),
11701 )
11702 .to_string();
11703
11704 assert!(
11705 err.starts_with(
11706 "could not push to origin/issue-7. [rejected] HEAD -> issue-7 (stale info)"
11707 ),
11708 "{err}"
11709 );
11710 }
11711
11712 #[test]
11713 fn a_fork_is_reached_by_whatever_reaches_origin() {
11714 let cases = [
11715 (
11716 "git@github.com:example/project.git",
11717 Some("git@github.com:someone/project.git"),
11718 ),
11719 (
11721 "github.com:example/project.git",
11722 Some("github.com:someone/project.git"),
11723 ),
11724 (
11726 "git@github-work:example/project.git",
11727 Some("git@github-work:someone/project.git"),
11728 ),
11729 (
11731 "ssh://git@ssh.github.com:443/example/project.git",
11732 Some("ssh://git@ssh.github.com:443/someone/project.git"),
11733 ),
11734 (
11735 "https://github.com/example/project",
11736 Some("https://github.com/someone/project"),
11737 ),
11738 (
11739 "https://ghe.example.com/scm/example/project.git/",
11740 Some("https://ghe.example.com/scm/someone/project.git"),
11741 ),
11742 (
11743 "git@host:/srv/example/project.git",
11744 Some("git@host:/srv/someone/project.git"),
11745 ),
11746 ("/srv/git/project.git", None),
11747 ("https://github.com/project", None),
11748 ("", None),
11749 ];
11750 for (origin, want) in cases {
11751 assert_eq!(
11752 want.map(str::to_string),
11753 fork_url(origin, "someone/project"),
11754 "{origin}"
11755 );
11756 }
11757 }
11758
11759 #[test]
11760 fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11761 let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11762 "ssh_url":"git@github.com:someone/project.git",
11763 "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11764 let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11765 "sshUrl":"git@github.com:someone/project.git",
11766 "url":"https://github.com/someone/project"}]"#;
11767
11768 let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11769 assert_eq!("git@github-work:someone/project.git", fork.url);
11770 assert_eq!("someone", fork.owner);
11771 assert_eq!("someone/project", fork.full_name);
11772 let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11773 .unwrap()
11774 .unwrap();
11775 assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11776 assert_eq!(
11777 None,
11778 fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11779 );
11780
11781 let fork = fork_from_api(made, "https://github.com/project").unwrap();
11784 assert_eq!("https://github.com/someone/project.git", fork.url);
11785 let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11786 assert_eq!("git@github.com:someone/project.git", fork.url);
11787
11788 assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11789 assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11790 }
11791
11792 #[test]
11793 fn only_the_viewers_own_fork_can_be_pushed_to() {
11794 let pr = |owner: &str, name: &str| PrView {
11795 number: 9,
11796 url: String::new(),
11797 title: String::new(),
11798 body: String::new(),
11799 head_ref_name: "issue-7".into(),
11800 base_ref_name: "main".into(),
11801 state: "OPEN".into(),
11802 closing_issues_references: Vec::new(),
11803 is_cross_repository: true,
11804 head_repository_owner: Some(crate::model::Author {
11805 login: owner.into(),
11806 }),
11807 head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11808 };
11809 let origin = "git@github.com:example/project.git";
11810
11811 assert_eq!(
11812 Some(PushTarget::Fork(Fork {
11813 owner: "Someone".into(),
11814 full_name: "Someone/project".into(),
11815 url: "git@github.com:Someone/project.git".into(),
11816 })),
11817 own_fork_target(&pr("Someone", "project"), "someone", origin)
11818 );
11819 assert_eq!(
11820 None,
11821 own_fork_target(&pr("other", "project"), "someone", origin)
11822 );
11823 assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11824 assert_eq!(
11825 None,
11826 own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11827 );
11828 let mut unnamed = pr("someone", "project");
11829 unnamed.head_repository_owner = None;
11830 assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11831 }
11832
11833 #[cfg(unix)]
11836 #[test]
11837 fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11838 let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11839 test_git(
11840 &work,
11841 &[
11842 "push",
11843 "-q",
11844 fork.to_str().unwrap(),
11845 "issue-7:refs/heads/issue-7",
11846 ],
11847 );
11848 let published = test_git(&work, &["rev-parse", "issue-7"]);
11849 test_git(&work, &["checkout", "-q", "main"]);
11850 let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11851 let pr = PrView {
11852 number: 31,
11853 url: String::new(),
11854 title: String::new(),
11855 body: String::new(),
11856 head_ref_name: "issue-7".into(),
11857 base_ref_name: "main".into(),
11858 state: "OPEN".into(),
11859 closing_issues_references: Vec::new(),
11860 is_cross_repository: true,
11861 head_repository_owner: None,
11862 head_repository: None,
11863 };
11864
11865 let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11866
11867 assert_eq!("issue-7", branch);
11868 assert_eq!(
11869 published.trim(),
11870 test_git(&path, &["rev-parse", "HEAD"]).trim()
11871 );
11872 std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11873 test_git(&path, &["commit", "-qam", "Answer a comment"]);
11874 repo.push_to(&path, &target, &branch, Some(published.trim()))
11875 .unwrap();
11876 assert_eq!(
11877 test_git(&path, &["rev-parse", "HEAD"]).trim(),
11878 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11879 );
11880 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11881 .trim()
11882 .is_empty());
11883 }
11884
11885 #[cfg(unix)]
11886 #[test]
11887 fn with_forking_off_a_refused_push_says_why_and_how() {
11888 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11889
11890 let err = repo
11891 .publish_new_branch(&work, "issue-7", "main")
11892 .unwrap_err()
11893 .to_string();
11894
11895 assert!(
11896 err.contains("Permission to example/project.git denied to someone."),
11897 "{err}"
11898 );
11899 assert!(err.contains("fork_when_denied = true"), "{err}");
11900 assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11901 .trim()
11902 .is_empty());
11903 assert_eq!(
11904 WriteSummary {
11905 attempted: 1,
11906 failed: 1
11907 },
11908 repo.write_summary()
11909 );
11910 }
11911}
11912
11913#[cfg(test)]
11914mod comment_page_tests {
11915 use super::*;
11916
11917 #[test]
11918 fn a_single_merged_array_is_read() {
11919 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11920 assert_eq!(2, pages.len());
11921 assert_eq!(Some(2), pages[1]["id"].as_i64());
11922 }
11923
11924 #[test]
11925 fn concatenated_pages_from_an_older_gh_are_read_too() {
11926 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11927 assert_eq!(2, pages.len());
11928 }
11929
11930 #[test]
11934 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11935 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11936 let pages = parse_comment_pages(text);
11937 assert_eq!(2, pages.len(), "{pages:?}");
11938 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11939 }
11940
11941 #[test]
11942 fn empty_output_is_no_comments_not_a_panic() {
11943 assert!(parse_comment_pages("").is_empty());
11944 assert!(parse_comment_pages(" ").is_empty());
11945 assert!(parse_comment_pages("[]").is_empty());
11946 }
11947
11948 #[test]
11949 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11950 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11951 }
11952
11953 #[test]
11954 fn a_write_postcheck_rejects_truncated_comment_pages() {
11955 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11956 assert!(
11957 error.to_string().contains("unexpected comment pages"),
11958 "{error}"
11959 );
11960 }
11961
11962 #[test]
11963 fn a_write_postcheck_rejects_empty_or_non_array_output() {
11964 assert!(try_parse_comment_pages("").is_err());
11965 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11966 assert!(try_parse_comment_pages("[]").is_ok());
11967 }
11968
11969 #[test]
11970 fn state_is_found_in_the_last_matching_comment() {
11971 let payload = |round: u32| {
11972 format!(
11973 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11974 )
11975 };
11976 let text = serde_json::to_string(&serde_json::json!([
11977 {"id": 1, "body": payload(1)},
11978 {"id": 2, "body": "looks good to me"},
11979 {"id": 3, "body": payload(5)},
11980 ]))
11981 .unwrap();
11982 let pages = parse_comment_pages(&text);
11983 let last = pages
11984 .iter()
11985 .rev()
11986 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11987 .unwrap();
11988 assert_eq!(5, last.round);
11989 }
11990}
11991
11992#[cfg(test)]
11993mod linked_pr_tests {
11994 use super::*;
11995
11996 const REAL_PAYLOAD: &str = r#"[
12001 {"number":14252,"title":"fix: reject leading-dash branch names",
12002 "url":"https://github.com/cli/cli/pull/14252",
12003 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
12004 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
12005 "url":"https://github.com/cli/cli/issues/14238"}]},
12006 {"number":14217,"title":"another change",
12007 "url":"https://github.com/cli/cli/pull/14217",
12008 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
12009 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
12010 "url":"https://github.com/cli/cli/issues/9761"}]},
12011 {"number":14200,"title":"unlinked work",
12012 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
12013 ]"#;
12014
12015 #[test]
12016 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
12017 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
12018 assert_eq!(14252, pr.number);
12019 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
12020 }
12021
12022 #[test]
12023 fn the_right_pr_is_picked_out_of_several() {
12024 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
12025 }
12026
12027 #[test]
12028 fn an_issue_nobody_is_working_on_finds_nothing() {
12029 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
12030 }
12031
12032 #[test]
12033 fn an_unlinked_pr_is_never_matched() {
12034 for issue in [14200, 0, 1] {
12036 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
12037 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
12038 }
12039 }
12040 }
12041
12042 #[test]
12043 fn empty_or_broken_output_is_none_rather_than_a_panic() {
12044 assert!(find_linked_pr("", 1).is_none());
12045 assert!(find_linked_pr("[]", 1).is_none());
12046 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
12047 assert!(find_linked_pr("[{\"number\":", 1).is_none());
12048 }
12049
12050 #[test]
12057 fn an_unreadable_list_is_not_an_empty_list() {
12058 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
12059 assert!(linked_pr_rows("[{\"number\":").is_none());
12060 assert!(linked_pr_rows("").is_none());
12061
12062 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
12063 assert!(empty.is_empty());
12064 assert!(pick_linked_pr(&empty, 14238).is_none());
12065
12066 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
12067 assert_eq!(3, listed.len());
12068 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
12069 }
12070
12071 #[test]
12073 fn pr_view_reads_the_cross_repository_flag() {
12074 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
12075 "baseRefName":"main","state":"OPEN",
12076 "closingIssuesReferences":[],"isCrossRepository":true}"#;
12077 let pr: PrView = serde_json::from_str(json).unwrap();
12078 assert!(pr.is_cross_repository);
12079 assert!(pr.is_open());
12080
12081 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
12082 assert!(
12083 !serde_json::from_str::<PrView>(&same_repo)
12084 .unwrap()
12085 .is_cross_repository
12086 );
12087 }
12088}
12089
12090#[cfg(test)]
12091mod min_number_tests {
12092 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
12098 let mut numbers: Vec<i64> = open.to_vec();
12099 numbers.sort_unstable();
12100 if min_number > 0 {
12101 numbers.retain(|n| *n >= min_number);
12102 }
12103 numbers.truncate(limit);
12104 numbers
12105 }
12106
12107 #[test]
12108 fn the_floor_is_applied_before_the_cap_not_after() {
12109 let open = [12, 13, 14, 480, 481, 482];
12110 assert_eq!(vec![480, 481], pick(&open, 2, 480));
12111 assert!(!pick(&open, 2, 480).is_empty());
12114 }
12115
12116 #[test]
12117 fn no_floor_keeps_the_old_behaviour() {
12118 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
12119 }
12120
12121 #[test]
12122 fn the_floor_is_inclusive() {
12123 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
12124 }
12125
12126 #[test]
12127 fn a_floor_above_everything_open_yields_nothing() {
12128 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
12129 }
12130}
12131
12132#[cfg(test)]
12133mod review_record_tests {
12134 use super::*;
12135 use serde_json::json;
12136
12137 #[test]
12138 fn prune_state_knows_which_files_belong_to_a_pull_request() {
12139 assert_eq!(Some(12), state_file_number("pr-12.json"));
12140 assert_eq!(Some(12), state_file_number("review-12.json"));
12141 assert_eq!(Some(12), state_file_number("checkin-12.json"));
12142 for shared in [
12143 "spend.json",
12144 "implementors.json",
12145 "contested.json",
12146 "listen.json",
12147 "pr-.json",
12148 "pr-12.json.tmp",
12149 "pr-x.json",
12150 ] {
12151 assert_eq!(None, state_file_number(shared), "{shared}");
12152 }
12153 }
12154
12155 #[test]
12156 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
12157 let reviews = vec![
12158 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
12159 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
12160 ];
12161 assert!(has_viewer_review(&reviews, "me", &["the body"], "aaa"));
12162 assert!(!has_viewer_review(&reviews, "me", &["other"], "aaa"));
12163 assert!(!has_viewer_review(&reviews, "alice", &["the body"], "aaa"));
12164 }
12165
12166 #[test]
12170 fn a_review_that_landed_with_either_body_is_not_sent_again() {
12171 let brief = vec![
12172 json!({"user": {"login": "me"}, "body": "brief", "commit_id": "aaa", "state": "COMMENTED"}),
12173 ];
12174 let full = vec![
12175 json!({"user": {"login": "me"}, "body": "full", "commit_id": "aaa", "state": "CHANGES_REQUESTED"}),
12176 ];
12177 for landed in [&brief, &full] {
12178 assert!(has_viewer_review(landed, "me", &["brief", "full"], "aaa"));
12179 assert!(has_viewer_review(landed, "me", &["full", "brief"], "aaa"));
12180 assert!(!has_viewer_review(landed, "me", &["brief", "full"], "bbb"));
12181 }
12182 assert!(!has_viewer_review(&brief, "me", &["full"], "aaa"));
12183 assert!(!has_viewer_review(&full, "me", &[], "aaa"));
12184 }
12185
12186 #[test]
12190 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
12191 let older = vec![
12192 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
12193 ];
12194 assert!(!has_viewer_review(&older, "me", &["the body"], "bbb"));
12195 let dismissed = vec![
12196 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
12197 ];
12198 assert!(!has_viewer_review(&dismissed, "me", &["the body"], "bbb"));
12199 }
12200
12201 #[test]
12202 fn a_response_splits_into_status_headers_and_body() {
12203 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";
12204 let resp = parse_api_response(text).expect("a response");
12205 assert_eq!(200, resp.status);
12206 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
12207 assert_eq!(
12208 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
12209 resp.headers.get("last-modified")
12210 );
12211 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
12212
12213 let not_modified =
12214 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
12215 assert_eq!(304, not_modified.status);
12216 assert!(not_modified.body.is_empty());
12217
12218 assert_eq!(None, parse_api_response(""));
12219 assert_eq!(None, parse_api_response("gh: command not found\n"));
12220 }
12221
12222 #[test]
12223 fn review_requests_are_read_off_the_timeline_with_who_and_when() {
12224 let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
12225 {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
12226 {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
12227 {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
12228 {}
12229 ]}}}}})
12230 .to_string();
12231 let made = parse_review_requests(&text).expect("requests");
12232 assert_eq!(2, made.len());
12233 assert_eq!("2026-10-20T10:00:00Z", made[0].at);
12234 assert_eq!("viewer", made[0].who.login);
12235 assert!(made[0].who.slug.is_empty());
12236 assert_eq!("2026-10-20T11:00:00Z", made[1].at);
12237 assert!(made[1].who.login.is_empty());
12238 assert_eq!("example/ours", made[1].who.slug);
12239
12240 let none =
12241 json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
12242 assert_eq!(
12243 Some(0),
12244 parse_review_requests(&none.to_string()).map(|m| m.len())
12245 );
12246 assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
12247 assert!(parse_review_requests("gh: not json").is_none());
12248 }
12249
12250 #[test]
12251 fn assigned_issues_are_read_with_who_holds_them_and_when_each_was_assigned() {
12252 let text = json!({"data": {"repository": {"issues": {"nodes": [
12253 {
12254 "number": 1144,
12255 "title": "Send max",
12256 "assignees": {"nodes": [{"login": "viewer"}]},
12257 "timelineItems": {"nodes": [
12258 {"createdAt": "2026-08-10T10:00:00Z", "assignee": {"login": "viewer"}},
12259 {"createdAt": "2026-11-03T12:36:22Z", "assignee": {"login": "a-bot"}},
12260 {"createdAt": "2026-11-03T12:40:00Z", "assignee": null},
12261 {"assignee": {"login": "viewer"}},
12262 {}
12263 ]}
12264 },
12265 {"title": "no number"},
12266 {"number": 7}
12267 ]}}}})
12268 .to_string();
12269 let issues = parse_assigned_issues(&text).expect("issues");
12270 assert_eq!(2, issues.len());
12271 assert_eq!(1144, issues[0].number);
12272 assert_eq!("Send max", issues[0].title);
12273 assert_eq!(vec!["viewer".to_string()], issues[0].assignees);
12274 assert_eq!(
12275 vec![
12276 ("2026-08-10T10:00:00Z".to_string(), "viewer".to_string()),
12277 ("2026-11-03T12:36:22Z".to_string(), "a-bot".to_string()),
12278 ],
12279 issues[0].assigned
12280 );
12281 assert_eq!(7, issues[1].number);
12282 assert!(issues[1].assignees.is_empty() && issues[1].assigned.is_empty());
12283
12284 let none = json!({"data": {"repository": {"issues": {"nodes": []}}}});
12285 assert_eq!(
12286 Some(0),
12287 parse_assigned_issues(&none.to_string()).map(|i| i.len())
12288 );
12289 assert!(parse_assigned_issues(r#"{"errors": [{"message": "no"}]}"#).is_none());
12290 assert!(parse_assigned_issues("gh: not json").is_none());
12291 }
12292
12293 #[test]
12294 fn one_issues_assignments_are_read_the_same_way() {
12295 let text = json!({"data": {"repository": {"issue": {
12296 "number": 42,
12297 "title": "t",
12298 "assignees": {"nodes": [{"login": "Viewer"}]},
12299 "timelineItems": {"nodes": [
12300 {"createdAt": "2026-11-03T12:36:22Z", "assignee": {"login": "Viewer"}}
12301 ]}
12302 }}}})
12303 .to_string();
12304 let issue = parse_issue_assignments(&text).expect("an issue");
12305 assert_eq!(42, issue.number);
12306 assert_eq!(
12307 Some("2026-11-03T12:36:22Z".to_string()),
12308 issue.newest_for("viewer")
12309 );
12310 assert!(parse_issue_assignments(r#"{"data": {"repository": {"issue": null}}}"#).is_none());
12311 assert!(parse_issue_assignments("gh: not json").is_none());
12312 }
12313
12314 #[test]
12315 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
12316 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
12317 let error = crate::error::SparError::call_failed("gh: boom");
12318 assert!(
12319 reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(landed)).is_ok()
12320 );
12321
12322 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
12323 let err = reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(missing))
12324 .unwrap_err();
12325 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
12326
12327 let unreadable = Err(crate::error::SparError::new("no network"));
12328 let err =
12329 reconcile_review_post(5, &["the body"], "me", "aaa", error, unreadable).unwrap_err();
12330 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
12331 assert!(err.to_string().contains("could not be verified"), "{err}");
12332 }
12333 #[test]
12334 fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
12335 let comments = vec![
12336 InlineComment {
12337 path: "src/a.rs".into(),
12338 line: 12,
12339 start_line: None,
12340 body: "one".into(),
12341 },
12342 InlineComment {
12343 path: "src/b.rs".into(),
12344 line: 9,
12345 start_line: Some(4),
12346 body: "two".into(),
12347 },
12348 ];
12349 assert_eq!(
12350 json!({
12351 "commit_id": "abc",
12352 "event": "COMMENT",
12353 "body": "the body",
12354 "comments": [
12355 {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
12356 {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
12357 "start_side": "RIGHT", "body": "two"},
12358 ],
12359 }),
12360 review_payload("abc", "COMMENT", "the body", &comments)
12361 );
12362 }
12363
12364 #[test]
12365 fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
12366 let diff = r#"diff --git a/src/a.rs b/src/a.rs
12367index 1111111..2222222 100644
12368--- a/src/a.rs
12369+++ b/src/a.rs
12370@@ -1,3 +1,4 @@ fn x
12371 one
12372+++ an added line that looks like a header
12373 two
12374 three
12375@@ -20 +21,2 @@
12376-old
12377+new
12378+more
12379@@ -30,1 +32,0 @@
12380-gone
12381diff --git a/gone.rs b/gone.rs
12382deleted file mode 100644
12383--- a/gone.rs
12384+++ /dev/null
12385@@ -1,2 +0,0 @@
12386-a
12387-b
12388diff --git a/old.rs b/renamed.rs
12389similarity index 90%
12390rename from old.rs
12391rename to renamed.rs
12392--- a/old.rs
12393+++ b/renamed.rs
12394@@ -5,0 +6,1 @@
12395+inserted
12396diff --git a/with space.rs b/with space.rs
12397--- a/with space.rs
12398+++ b/with space.rs
12399@@ -2 +2 @@
12400-x
12401+y
12402diff --git "a/sp\tace.rs" "b/sp\tace.rs"
12403--- "a/sp\tace.rs"
12404+++ "b/sp\tace.rs"
12405@@ -1 +1 @@
12406-x
12407+y
12408"#;
12409 let hunks = new_side_hunks(diff);
12410 assert_eq!(
12411 Hunks::from([
12412 ("renamed.rs".to_string(), vec![(6, 6)]),
12413 ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
12414 ("with space.rs".to_string(), vec![(2, 2)]),
12415 ]),
12416 hunks
12417 );
12418 }
12419
12420 #[test]
12421 fn only_a_refusal_is_final_enough_to_send_a_review_again() {
12422 let error = |text: &str| crate::error::SparError::new(text);
12423 assert!(refused(&error(
12424 "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
12425 )));
12426 assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
12427 assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
12428 assert!(!refused(&error("command timed out after 300s")));
12429 }
12430
12431 #[test]
12432 fn a_hunk_header_with_its_counts_left_out_counts_one() {
12433 assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
12434 assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
12435 assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
12436 assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
12437 }
12438}