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 fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
450 after
451 .files
452 .keys()
453 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
454 .cloned()
455 .collect()
456 }
457
458 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
468 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
469 paths.extend(after.files.keys());
470 paths.into_iter().any(|path| {
471 if self.files.get(path) == after.files.get(path)
472 && self.is_ignored(path) == after.is_ignored(path)
473 {
474 return false;
475 }
476 !self.generated_move(after, path)
477 })
478 }
479
480 pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
508 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
509 paths.extend(after.files.keys());
510 let mut leavings = ReadOnlyLeavings::default();
511 for path in paths {
512 if self.files.get(path) == after.files.get(path)
513 && self.is_ignored(path) == after.is_ignored(path)
514 {
515 continue;
516 }
517 if !self.ignored_file_move(after, path) {
518 leavings.condemning.push(path.clone());
519 } else if !is_generated_artifact(path) {
520 leavings.tolerated.push(path.clone());
521 }
522 }
523 leavings
524 }
525
526 fn generated_move(&self, after: &Self, path: &Path) -> bool {
540 is_generated_artifact(path) && self.disposable_move(after, path)
541 }
542
543 fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
547 if path.starts_with(WORKTREE_DIR)
548 && self.files.get(path).is_some_and(|file| file.kind == 3)
549 && self.files.get(path) != after.files.get(path)
550 {
551 return false;
552 }
553 self.disposable_move(after, path)
554 }
555
556 fn disposable_move(&self, after: &Self, path: &Path) -> bool {
559 self.disposable(path) && after.disposable(path)
560 }
561
562 fn disposable(&self, path: &Path) -> bool {
565 !self.files.contains_key(path) || self.is_ignored(path)
566 }
567}
568
569impl GitState {
570 pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
576 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
577 prefixes.extend(after.repositories.keys());
578 let mut notes: Vec<String> = Vec::new();
579 let mut paths: Vec<PathBuf> = Vec::new();
580 for prefix in prefixes {
581 let label = describe_repository(prefix);
582 match (
583 self.repositories.get(prefix),
584 after.repositories.get(prefix),
585 ) {
586 (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
587 (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
588 (Some(before), Some(current)) => {
589 if before.head != current.head {
590 notes.push(format!("HEAD moved in {label}"));
591 }
592 if before.unsafe_index_flags != current.unsafe_index_flags {
593 notes.push(format!("index flags changed in {label}"));
594 }
595 paths.extend(
596 differing_keys(&before.gitlinks, ¤t.gitlinks)
597 .chain(differing_keys(&before.tracked, ¤t.tracked))
598 .map(|path| prefix.join(path)),
599 );
600 }
601 (None, None) => {}
602 }
603 }
604 if !paths.is_empty() {
605 paths.sort();
606 paths.dedup();
607 notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
608 }
609 (!notes.is_empty()).then(|| notes.join("; "))
610 }
611
612 pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
615 let difference = self.describe_difference(after)?;
616 let Some(moved) = self.moved_wholesale(after) else {
617 return Some(difference);
618 };
619 if said_once("moved", cwd, &moved) {
622 logwarn!(
623 "the checkout at {} moved while a read-only call was reading it: {moved}. The \
624 answer was kept, and it describes the tree as it stood before the move.",
625 cwd.display()
626 );
627 }
628 None
629 }
630
631 fn moved_wholesale(&self, after: &Self) -> Option<String> {
648 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
649 prefixes.extend(after.repositories.keys());
650 let mut notes: Vec<String> = Vec::new();
651 for prefix in prefixes {
652 let before = self.repositories.get(prefix)?;
654 let current = after.repositories.get(prefix)?;
655 if before.unsafe_index_flags != current.unsafe_index_flags
656 || before.gitlinks != current.gitlinks
657 {
658 return None;
659 }
660 let moved: Vec<&PathBuf> = differing_keys(&before.tracked, ¤t.tracked).collect();
661 if before.head == current.head {
662 if moved.is_empty() {
663 continue;
664 }
665 return None;
666 }
667 if !moved.iter().all(|path| {
668 checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
669 }) {
670 return None;
671 }
672 let label = describe_repository(prefix);
673 notes.push(match moved.len() {
674 0 => format!("HEAD moved in {label}"),
675 count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
676 });
677 }
678 (!notes.is_empty()).then(|| notes.join("; "))
679 }
680}
681
682fn checked_out(entry: Option<&TrackedEntry>) -> bool {
689 entry.is_none_or(|entry| {
690 entry
691 .worktree
692 .as_ref()
693 .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
694 })
695}
696
697pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
708 if paths.is_empty() {
709 return;
710 }
711 let named = list_paths(paths);
712 if !said_once("ignored", cwd, &named) {
713 return;
714 }
715 logdim!(
716 "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
717 project ignores these paths.",
718 paths.len(),
719 cwd.display()
720 );
721}
722
723fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
730 static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
731 SAID.lock()
732 .unwrap_or_else(std::sync::PoisonError::into_inner)
733 .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
734}
735
736fn describe_repository(prefix: &Path) -> String {
739 if prefix.as_os_str().is_empty() {
740 "the worktree".to_string()
741 } else {
742 format!("the submodule at {:?}", prefix.as_os_str())
743 }
744}
745
746fn differing_keys<'a, V: PartialEq>(
748 before: &'a BTreeMap<PathBuf, V>,
749 after: &'a BTreeMap<PathBuf, V>,
750) -> impl Iterator<Item = &'a PathBuf> {
751 let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
752 keys.extend(after.keys());
753 keys.into_iter()
754 .filter(move |key| before.get(*key) != after.get(*key))
755}
756
757#[derive(Default)]
763pub(crate) struct ReadOnlyLeavings {
764 pub(crate) tolerated: Vec<PathBuf>,
768 pub(crate) condemning: Vec<PathBuf>,
770}
771
772pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
776 match (first, second) {
777 (None, None) => None,
778 (Some(one), None) | (None, Some(one)) => Some(one),
779 (Some(first), Some(second)) => Some(format!("{first}; {second}")),
780 }
781}
782
783pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
788 const SHOWN: usize = 5;
789 let mut listed = paths
790 .iter()
791 .take(SHOWN)
792 .map(|path| format!("{:?}", path.as_os_str()))
793 .collect::<Vec<_>>()
794 .join(", ");
795 if paths.len() > SHOWN {
796 listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
797 }
798 listed
799}
800
801#[derive(Default)]
808struct IgnoredArtifacts {
809 new_paths: BTreeSet<PathBuf>,
810 changed_paths: BTreeSet<PathBuf>,
811 removed_paths: BTreeSet<PathBuf>,
812}
813
814impl IgnoredArtifacts {
815 fn left(&mut self, allowed: AllowedArtifacts) {
816 self.new_paths.extend(allowed.present);
817 self.removed_paths.extend(allowed.removed);
818 }
819
820 fn changed(&mut self, allowed: AllowedArtifacts) {
821 self.changed_paths.extend(allowed.present);
822 self.removed_paths.extend(allowed.removed);
823 }
824
825 fn report(&self, cwd: &Path) {
827 let paths: BTreeSet<_> = self
828 .new_paths
829 .iter()
830 .chain(&self.changed_paths)
831 .chain(&self.removed_paths)
832 .cloned()
833 .collect();
834 if !paths.is_empty() {
835 logdim!(
836 "the editing call changed {} ignored file(s) in {}: {}. They are outside \
837 the commit; cleanup checks them separately.",
838 paths.len(),
839 cwd.display(),
840 list_paths(&paths.into_iter().collect::<Vec<_>>())
841 );
842 }
843 }
844}
845
846#[derive(Default)]
850struct AllowedArtifacts {
851 present: Vec<PathBuf>,
852 removed: Vec<PathBuf>,
853}
854
855impl AllowedArtifacts {
856 fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
857 let (present, removed) = paths
858 .into_iter()
859 .partition(|path| after.files.contains_key(path));
860 Self { present, removed }
861 }
862}
863
864fn is_generated_artifact(path: &Path) -> bool {
870 const DIRECTORIES: &[&str] = &[
871 "target",
872 "dist",
873 "node_modules",
874 "__pycache__",
875 ".pytest_cache",
876 ".mypy_cache",
877 ".ruff_cache",
878 ".tox",
879 ".nox",
880 ".venv",
881 "venv",
882 ".gradle",
883 ".build",
884 "DerivedData",
885 ".next",
886 ".nuxt",
887 ".svelte-kit",
888 ".turbo",
889 "coverage",
890 ];
891 path.components().any(|component| {
892 let std::path::Component::Normal(name) = component else {
893 return false;
894 };
895 DIRECTORIES
896 .iter()
897 .any(|directory| name == OsStr::new(directory))
898 })
899}
900
901fn is_desktop_metadata(path: &Path) -> bool {
907 path.file_name() == Some(OsStr::new(".DS_Store"))
908}
909
910fn merge_pr_args<'a>(
911 number: &'a str,
912 expected_head: Option<&'a str>,
913 delete_branch: bool,
914) -> Vec<&'a str> {
915 let mut args = vec!["pr", "merge", number, "--squash"];
916 if delete_branch {
917 args.push("--delete-branch");
918 }
919 if let Some(expected_head) = expected_head {
920 args.extend(["--match-head-commit", expected_head]);
921 }
922 args
923}
924
925fn push_denial(message: &str) -> Option<&str> {
935 message
936 .lines()
937 .map(|line| {
938 let line = line.trim();
939 let line = line.strip_prefix("ERROR:").unwrap_or(line);
940 line.strip_prefix("remote:").unwrap_or(line).trim()
941 })
942 .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
943}
944
945fn push_rejection(message: &str) -> Option<&str> {
948 message
949 .lines()
950 .map(|line| line.trim().trim_start_matches('!').trim())
951 .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
952}
953
954fn push_error(
955 target: &PushTarget,
956 branch: &str,
957 e: &crate::error::SparError,
958) -> crate::error::SparError {
959 let message = e.to_string();
960 if let Some(denied) = push_denial(&message) {
961 return spar_err!("could not push to {}. {denied}", target.describe(branch));
962 }
963 spar_err!(
964 "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
965 target.describe(branch),
966 push_rejection(&message).unwrap_or(e.last_line())
967 )
968}
969
970fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
979 let origin = origin_url.trim();
980 let path_start = match origin.find("://") {
981 Some(scheme) => {
982 let host = scheme + 3;
983 host + origin[host..].find('/')? + 1
984 }
985 None => {
986 let colon = origin.find(':')?;
989 if origin[..colon].contains('/') {
990 return None;
991 }
992 colon + 1
993 }
994 };
995 let path = origin[path_start..].trim_end_matches('/');
996 let (path, suffix) = match path.strip_suffix(".git") {
997 Some(bare) => (bare, ".git"),
998 None => (path, ""),
999 };
1000 let segments: Vec<&str> = path.split('/').collect();
1001 if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
1002 return None;
1003 }
1004 let prefix = &segments[..segments.len() - 2];
1005 let prefix = if prefix.is_empty() {
1006 String::new()
1007 } else {
1008 format!("{}/", prefix.join("/"))
1009 };
1010 Some(format!(
1011 "{}{prefix}{full_name}{suffix}",
1012 &origin[..path_start]
1013 ))
1014}
1015
1016fn fork_reached_like_origin(
1021 owner: String,
1022 full_name: String,
1023 origin_url: &str,
1024 ssh_url: String,
1025 https_url: String,
1026) -> Fork {
1027 let origin = origin_url.trim();
1028 let url = fork_url(origin, &full_name).unwrap_or_else(|| {
1029 if origin.starts_with("git@") || origin.starts_with("ssh://") {
1030 ssh_url
1031 } else {
1032 https_url
1033 }
1034 });
1035 Fork {
1036 owner,
1037 full_name,
1038 url,
1039 }
1040}
1041
1042#[derive(Deserialize)]
1043struct ForkOwner {
1044 login: String,
1045}
1046
1047fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
1049 #[derive(Deserialize)]
1050 struct Answer {
1051 full_name: String,
1052 owner: ForkOwner,
1053 ssh_url: String,
1054 clone_url: String,
1055 }
1056
1057 let answer: Answer = serde_json::from_str(answer.trim())
1058 .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1059 Ok(fork_reached_like_origin(
1060 answer.owner.login,
1061 answer.full_name,
1062 origin_url,
1063 answer.ssh_url,
1064 answer.clone_url,
1065 ))
1066}
1067
1068fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1071 #[derive(Deserialize)]
1072 #[serde(rename_all = "camelCase")]
1073 struct Node {
1074 name_with_owner: String,
1075 owner: ForkOwner,
1076 ssh_url: String,
1077 url: String,
1078 }
1079
1080 let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1081 .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1082 Ok(nodes.into_iter().next().map(|node| {
1083 fork_reached_like_origin(
1084 node.owner.login,
1085 node.name_with_owner,
1086 origin_url,
1087 node.ssh_url,
1088 node.url,
1089 )
1090 }))
1091}
1092
1093fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1096 let owner = pr.head_repository_owner.as_ref()?.login.trim();
1097 let name = pr.head_repository.as_ref()?.name.trim();
1098 if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1099 return None;
1100 }
1101 let full_name = format!("{owner}/{name}");
1102 Some(PushTarget::Fork(Fork {
1103 owner: owner.to_string(),
1104 url: fork_url(origin_url, &full_name)?,
1105 full_name,
1106 }))
1107}
1108
1109fn reconcile_pr_creation(
1110 branch: &str,
1111 created: Result<String>,
1112 found: Result<Option<PrRef>>,
1113) -> Result<PrRef> {
1114 match (created, found) {
1115 (_, Ok(Some(pr))) => Ok(pr),
1116 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1117 "PR creation reported success but none was found for {branch}"
1118 ))),
1119 (Err(create), Ok(None)) => Err(spar_err!(
1120 "could not open a PR for {branch}. {}",
1121 create.last_line()
1122 )),
1123 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1124 "PR creation reported success for {branch}, but it could not be verified. {}",
1125 check.last_line()
1126 ))),
1127 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1128 "could not open a PR for {branch}. {} The result could not be verified: {}",
1129 create.last_line(),
1130 check.last_line()
1131 ))),
1132 }
1133}
1134
1135fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1136 #[derive(Deserialize)]
1137 #[serde(rename_all = "camelCase")]
1138 struct Row {
1139 number: i64,
1140 #[serde(default)]
1141 url: String,
1142 #[serde(default)]
1143 title: String,
1144 base_ref_name: String,
1145 }
1146
1147 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1148 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1149 })?;
1150 Ok(rows
1151 .into_iter()
1152 .find(|row| row.base_ref_name == base)
1153 .map(|row| PrRef {
1154 number: row.number,
1155 url: row.url,
1156 title: row.title,
1157 }))
1158}
1159
1160fn signed(body: &str) -> String {
1163 if body.contains(COMMENT_MARKER) {
1164 return body.to_string();
1165 }
1166 format!("{body}\n\n{COMMENT_MARKER}")
1167}
1168
1169pub fn is_spar_comment(body: &str) -> bool {
1171 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1172}
1173
1174#[derive(Debug, Clone, Default, PartialEq, Eq)]
1176pub struct ApiResponse {
1177 pub status: u16,
1178 pub headers: BTreeMap<String, String>,
1180 pub body: String,
1181}
1182
1183pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1187 let mut lines = stdout.split_inclusive('\n');
1188 let first = lines.next()?.trim_end_matches(['\r', '\n']);
1189 if !first.starts_with("HTTP/") {
1190 return None;
1191 }
1192 let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1193 let mut headers = BTreeMap::new();
1194 let mut body = String::new();
1195 let mut in_body = false;
1196 for line in lines {
1197 if in_body {
1198 body.push_str(line);
1199 continue;
1200 }
1201 let bare = line.trim_end_matches(['\r', '\n']);
1202 if bare.is_empty() {
1203 in_body = true;
1204 continue;
1205 }
1206 if let Some((name, value)) = bare.split_once(':') {
1207 headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1208 }
1209 }
1210 Some(ApiResponse {
1211 status,
1212 headers,
1213 body,
1214 })
1215}
1216
1217const REQUESTS_QUERY: &str = "\
1220query($owner: String!, $repo: String!, $number: Int!) {
1221 repository(owner: $owner, name: $repo) {
1222 pullRequest(number: $number) {
1223 timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1224 nodes {
1225 ... on ReviewRequestedEvent {
1226 createdAt
1227 requestedReviewer {
1228 ... on User { login }
1229 ... on Team { combinedSlug }
1230 }
1231 }
1232 }
1233 }
1234 }
1235 }
1236}";
1237
1238pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1242 let v: Value = serde_json::from_str(text).ok()?;
1243 let nodes = v
1244 .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1245 .as_array()?;
1246 let text_at = |node: &Value, path: &str| {
1247 node.pointer(path)
1248 .and_then(Value::as_str)
1249 .unwrap_or_default()
1250 .to_string()
1251 };
1252 Some(
1253 nodes
1254 .iter()
1255 .map(|node| crate::model::ReviewRequested {
1256 at: text_at(node, "/createdAt"),
1257 who: crate::model::Login {
1258 login: text_at(node, "/requestedReviewer/login"),
1259 slug: text_at(node, "/requestedReviewer/combinedSlug"),
1260 },
1261 })
1262 .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1263 .collect(),
1264 )
1265}
1266
1267pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1271 let stem = name.strip_suffix(".json")?;
1272 ["pr-", "review-", "checkin-"]
1273 .iter()
1274 .find_map(|prefix| stem.strip_prefix(prefix))
1275 .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1276 .and_then(|digits| digits.parse().ok())
1277}
1278
1279fn has_viewer_review(reviews: &[Value], viewer: &str, body: &str, commit: &str) -> bool {
1284 reviews.iter().any(|review| {
1285 let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1286 let standing = !matches!(
1287 field("state").to_uppercase().as_str(),
1288 "DISMISSED" | "PENDING"
1289 );
1290 review
1291 .get("user")
1292 .and_then(|u| u.get("login"))
1293 .and_then(Value::as_str)
1294 .is_some_and(|login| crate::comments::same_login(login, viewer))
1295 && field("body") == body
1296 && field("commit_id").eq_ignore_ascii_case(commit)
1297 && standing
1298 })
1299}
1300
1301fn reconcile_review_post(
1302 number: i64,
1303 body: &str,
1304 viewer: &str,
1305 commit: &str,
1306 post_error: crate::error::SparError,
1307 reviews: Result<Vec<Value>>,
1308) -> Result<()> {
1309 match reviews {
1310 Ok(reviews) if has_viewer_review(&reviews, viewer, body, commit) => Ok(()),
1311 Ok(_) => Err(post_error),
1312 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1313 "could not submit a review on #{number}. {} The result could not be verified: {}",
1314 post_error.last_line(),
1315 read_error.last_line()
1316 ))),
1317 }
1318}
1319
1320fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1325 let comments: Vec<Value> = comments
1326 .iter()
1327 .map(|c| {
1328 let mut row = serde_json::json!({
1329 "path": c.path,
1330 "line": c.line,
1331 "side": "RIGHT",
1332 "body": c.body,
1333 });
1334 if let Some(start) = c.start_line {
1335 row["start_line"] = serde_json::json!(start);
1336 row["start_side"] = serde_json::json!("RIGHT");
1337 }
1338 row
1339 })
1340 .collect();
1341 serde_json::json!({
1342 "commit_id": commit,
1343 "event": event,
1344 "body": body,
1345 "comments": comments,
1346 })
1347}
1348
1349fn refused(e: &crate::error::SparError) -> bool {
1353 e.to_string().contains("(HTTP 422)")
1354}
1355
1356pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1360
1361fn new_side_hunks(diff: &str) -> Hunks {
1368 let mut out = Hunks::new();
1369 let mut path: Option<String> = None;
1370 let (mut old_left, mut new_left) = (0u32, 0u32);
1371 for line in diff.lines() {
1372 if old_left > 0 || new_left > 0 {
1373 match line.as_bytes().first() {
1374 Some(b'+') => new_left = new_left.saturating_sub(1),
1375 Some(b'-') => old_left = old_left.saturating_sub(1),
1376 Some(b'\\') => {}
1377 _ => {
1378 old_left = old_left.saturating_sub(1);
1379 new_left = new_left.saturating_sub(1);
1380 }
1381 }
1382 continue;
1383 }
1384 if line.starts_with("diff --git ") {
1385 path = None;
1386 } else if let Some(rest) = line.strip_prefix("+++ ") {
1387 let rest = if rest.contains(' ') {
1390 rest.strip_suffix('\t').unwrap_or(rest)
1391 } else {
1392 rest
1393 };
1394 path = rest
1395 .strip_prefix("b/")
1396 .filter(|p| !p.is_empty())
1397 .map(str::to_string);
1398 } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1399 old_left = old_len;
1400 new_left = len;
1401 if let (Some(path), true) = (&path, len > 0 && start > 0) {
1402 out.entry(path.clone())
1403 .or_default()
1404 .push((start, start + len - 1));
1405 }
1406 }
1407 }
1408 out
1409}
1410
1411fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1413 let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1414 let (old, new) = ranges.split_once(" +")?;
1415 let range = |s: &str| -> Option<(u32, u32)> {
1416 match s.split_once(',') {
1417 Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1418 None => Some((s.parse().ok()?, 1)),
1419 }
1420 };
1421 Some((range(old)?, range(new)?))
1422}
1423
1424fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1425 comments.iter().any(|comment| {
1426 comment
1427 .get("body")
1428 .and_then(Value::as_str)
1429 .is_some_and(|seen| seen == body)
1430 })
1431}
1432
1433fn reconcile_comment_post(
1434 number: i64,
1435 body: &str,
1436 post_error: crate::error::SparError,
1437 comments: Result<Vec<Value>>,
1438) -> Result<()> {
1439 match comments {
1440 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1441 Ok(_) => Err(post_error),
1442 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1443 "could not comment on #{number}. {} The result could not be verified: {}",
1444 post_error.last_line(),
1445 read_error.last_line()
1446 ))),
1447 }
1448}
1449
1450fn reconcile_issue_edit(
1451 number: i64,
1452 wanted: &str,
1453 edit_error: crate::error::SparError,
1454 observed: Result<String>,
1455) -> Result<()> {
1456 match observed {
1457 Ok(body) if body == wanted => Ok(()),
1458 Ok(_) => Err(spar_err!(
1459 "could not rewrite the body of #{number}. {}",
1460 edit_error.last_line()
1461 )),
1462 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1463 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1464 edit_error.last_line(),
1465 read_error.last_line()
1466 ))),
1467 }
1468}
1469
1470fn issue_url_has_number(url: &str) -> bool {
1471 url.trim()
1472 .rsplit('/')
1473 .next()
1474 .and_then(|tail| tail.parse::<i64>().ok())
1475 .is_some_and(|number| number > 0)
1476}
1477
1478fn reconcile_issue_creation(
1479 title: &str,
1480 created: Result<String>,
1481 found: Result<Option<ExistingIssue>>,
1482) -> Result<String> {
1483 match (created, found) {
1484 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1485 (_, Ok(Some(issue))) => Ok(issue.url),
1486 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1487 "issue creation reported success but no matching issue was found for {title:?}"
1488 ))),
1489 (Err(create), Ok(None)) => Err(spar_err!(
1490 "could not file issue {title:?}. {}",
1491 create.last_line()
1492 )),
1493 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1494 "issue creation reported success for {title:?}, but it could not be verified. {}",
1495 check.last_line()
1496 ))),
1497 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1498 "could not file issue {title:?}. {} The result could not be verified: {}",
1499 create.last_line(),
1500 check.last_line()
1501 ))),
1502 }
1503}
1504
1505fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1506 if output.trim().is_empty() {
1507 return Ok(None);
1508 }
1509 for line in output.lines() {
1510 let mut fields = line.split_whitespace();
1511 let oid = fields.next().unwrap_or_default();
1512 let name = fields.next().unwrap_or_default();
1513 if name == remote_ref && !oid.is_empty() {
1514 return Ok(Some(oid.to_string()));
1515 }
1516 }
1517 Err(spar_err!(
1518 "origin returned an unexpected ref listing for {remote_ref}"
1519 ))
1520}
1521
1522fn reconcile_failed_split_push(
1523 branch: &str,
1524 push_error: crate::error::SparError,
1525 local: Result<String>,
1526 remote: Result<String>,
1527) -> std::result::Result<(), SplitPushError> {
1528 let remote_ref = format!("refs/heads/{branch}");
1529 match (local, remote) {
1530 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1531 Ok(Some(oid)) if oid == local.trim() => Ok(()),
1532 Ok(_) => Err(SplitPushError::new(
1533 format!(
1534 "could not create origin/{branch}. {} The remote branch is absent or points \
1535 somewhere else. Nothing was overwritten.",
1536 push_error.last_line()
1537 ),
1538 false,
1539 )),
1540 Err(check) => Err(SplitPushError::new(
1541 format!(
1542 "could not confirm whether origin/{branch} was created. {} The remote result \
1543 could not be verified: {}",
1544 push_error.last_line(),
1545 check.last_line()
1546 ),
1547 true,
1548 )),
1549 },
1550 (local, remote) => {
1551 let check = match (local, remote) {
1552 (Err(local), Err(remote)) => format!(
1553 "the local commit could not be read: {}; origin could not be read: {}",
1554 local.last_line(),
1555 remote.last_line()
1556 ),
1557 (Err(local), _) => {
1558 format!("the local commit could not be read: {}", local.last_line())
1559 }
1560 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1561 _ => unreachable!(),
1562 };
1563 Err(SplitPushError::new(
1564 format!(
1565 "could not confirm whether origin/{branch} was created. {} The result could \
1566 not be verified because {check}",
1567 push_error.last_line()
1568 ),
1569 true,
1570 ))
1571 }
1572 }
1573}
1574
1575impl Repo {
1576 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1577 let root =
1578 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1579 let inside = proc::run_str(
1582 &["git", "rev-parse", "--is-inside-work-tree"],
1583 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1584 )
1585 .unwrap_or_default();
1586 if inside.trim() != "true" {
1587 bail!("not a git repository: {}", root.display());
1588 }
1589 let repo = Self {
1590 root,
1591 style: cfg.style.clone(),
1592 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1593 state_store: cfg.loop_cfg.state_store,
1594 followups: cfg.loop_cfg.followups,
1595 drafts: cfg.loop_cfg.drafts,
1596 fork_when_denied: cfg.loop_cfg.fork_when_denied,
1597 setup: cfg.loop_cfg.setup.clone(),
1598 isolate_reads: cfg.loop_cfg.worktrees,
1599 keep_worktrees: cfg.loop_cfg.keep_worktrees,
1600 fork: OnceLock::new(),
1601 viewer: OnceLock::new(),
1602 checkpoints: Mutex::new(BTreeMap::new()),
1603 writes: WriteStats::default(),
1604 };
1605 repo.self_exclude();
1606 Ok(repo)
1607 }
1608
1609 fn self_exclude(&self) {
1617 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1618 let git_dir = git_dir.trim();
1619 if git_dir.is_empty() {
1620 return;
1621 }
1622 let path = Path::new(git_dir).join("info").join("exclude");
1623 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1624
1625 let wanted = [
1630 format!("/{WORKTREE_DIR}/"),
1631 format!("/{STATE_DIR}/"),
1632 format!("{RECOVERY_MARKER_PREFIX}*"),
1633 ];
1634 let missing: Vec<&String> = wanted
1635 .iter()
1636 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1637 .collect();
1638 if missing.is_empty() {
1639 return;
1640 }
1641
1642 use std::io::Write;
1643 if let Some(parent) = path.parent() {
1644 let _ = std::fs::create_dir_all(parent);
1645 }
1646 let mut block = String::new();
1647 if !existing.is_empty() && !existing.ends_with('\n') {
1648 block.push('\n');
1649 }
1650 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1651 for line in missing {
1652 block.push_str(line);
1653 block.push('\n');
1654 }
1655 if let Ok(mut file) = std::fs::OpenOptions::new()
1656 .create(true)
1657 .append(true)
1658 .open(&path)
1659 {
1660 let _ = file.write_all(block.as_bytes());
1661 }
1662 }
1663
1664 pub fn root(&self) -> &Path {
1665 &self.root
1666 }
1667
1668 pub(crate) fn write_summary(&self) -> WriteSummary {
1669 WriteSummary {
1670 attempted: self.writes.attempted.load(Ordering::Relaxed),
1671 failed: self.writes.failed.load(Ordering::Relaxed),
1672 }
1673 }
1674
1675 pub(crate) fn record_write<T, E>(
1676 &self,
1677 result: std::result::Result<T, E>,
1678 ) -> std::result::Result<T, E> {
1679 self.record_write_outcome(result.is_err());
1680 result
1681 }
1682
1683 pub(crate) fn record_failed_write<T, E>(
1684 &self,
1685 result: std::result::Result<T, E>,
1686 ) -> std::result::Result<T, E> {
1687 if result.is_err() {
1688 self.record_write_outcome(true);
1689 }
1690 result
1691 }
1692
1693 fn record_write_outcome(&self, failed: bool) {
1694 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1695 if failed {
1696 self.writes.failed.fetch_add(1, Ordering::Relaxed);
1697 }
1698 }
1699
1700 pub fn clean(&self, text: &str) -> Result<String> {
1706 let out = style::scrub(text, &self.style);
1707 let bad = style::violations(&out, &self.style);
1708 if !bad.is_empty() {
1709 bail!(
1710 "style gate could not clean text ({}): {}",
1711 bad.join(", "),
1712 style::clip(&out, 300)
1713 );
1714 }
1715 Ok(out)
1716 }
1717
1718 pub fn clean_body(&self, text: &str) -> Result<String> {
1720 self.clean(&style::body(text, &self.style))
1721 }
1722
1723 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1725 self.clean(&style::issue_body(text, &self.style))
1726 }
1727
1728 pub fn clean_title(&self, text: &str) -> Result<String> {
1739 Ok(style::title(&self.clean(text)?, &self.style))
1740 }
1741
1742 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1743 let title = self.record_failed_write(self.clean_title(text))?;
1744 if title.trim().is_empty() {
1745 return self.record_failed_write(Err(spar_err!(
1746 "nothing left of the title after cleaning it"
1747 )));
1748 }
1749 Ok(title)
1750 }
1751
1752 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1753 if self.followups == Followups::Issues {
1754 self.clean_nonempty_title_for_write(text)
1755 } else {
1756 self.clean_title(text)
1757 }
1758 }
1759
1760 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1763 ExecOpts::new()
1764 .cwd(cwd.unwrap_or(&self.root))
1765 .check(check)
1766 .timeout_secs(600)
1767 }
1768
1769 pub fn git(&self, args: &[&str]) -> Result<String> {
1770 self.git_at(None, args)
1771 }
1772
1773 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1774 let argv = git_without_maintenance_argv(args);
1775 proc::run(&argv, &self.git_opts(cwd, true))
1776 }
1777
1778 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1784 let argv = git_without_automation_argv(args);
1785 proc::run(
1786 &argv,
1787 &self.git_opts(Some(cwd), true).stop_descendants(true),
1788 )
1789 }
1790
1791 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1792 let argv = git_without_automation_argv(args);
1793 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1794 .map(|output| output.ok())
1795 }
1796
1797 pub fn git_try(&self, args: &[&str]) -> String {
1799 self.git_try_at(None, args)
1800 }
1801
1802 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1803 let argv = git_without_maintenance_argv(args);
1804 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1805 }
1806
1807 pub fn default_branch(&self, configured: &str) -> String {
1810 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1811 match refname.trim().rsplit('/').next() {
1812 Some(name) if !name.is_empty() => name.to_string(),
1813 _ => configured.to_string(),
1814 }
1815 }
1816
1817 pub fn branch_for_issue(&self, issue: i64) -> String {
1825 format!("{}issue-{issue}", self.branch_prefix)
1826 }
1827
1828 pub fn branch_for_pr(&self, number: i64) -> String {
1829 format!("{}pr-{number}", self.branch_prefix)
1830 }
1831
1832 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1841 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1842 }
1843
1844 fn ledger_path(&self) -> PathBuf {
1845 self.root.join(STATE_DIR).join("branches.json")
1846 }
1847
1848 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1849 std::fs::read_to_string(self.ledger_path())
1850 .ok()
1851 .and_then(|text| serde_json::from_str(&text).ok())
1852 .unwrap_or_default()
1853 }
1854
1855 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1856 let mut data = self.known_branches();
1857 data.insert(
1858 branch.to_string(),
1859 BranchRecord {
1860 kind: kind.to_string(),
1861 number,
1862 },
1863 );
1864 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1865 logdim!("could not record branch {branch}: {e}");
1866 }
1867 }
1868
1869 pub fn forget_branch(&self, branch: &str) {
1870 let mut data = self.known_branches();
1871 if data.remove(branch).is_none() {
1872 return;
1873 }
1874 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1875 logdim!("could not update the branch record: {e}");
1876 }
1877 }
1878
1879 fn worktree_path(&self, name: &str) -> PathBuf {
1882 self.root.join(WORKTREE_DIR).join(name)
1883 }
1884
1885 fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
1892 let argv = self.setup.argv();
1893 if argv.is_empty() {
1894 return Ok(());
1895 }
1896 let command = self.setup.describe();
1897 logdim!("setting up the {label}: {command}");
1898 let out = proc::exec(
1899 &argv,
1900 &ExecOpts::new()
1901 .cwd(path)
1902 .check(false)
1903 .timeout_secs(SETUP_TIMEOUT_SECS)
1904 .stop_descendants(true),
1905 )
1906 .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
1907 if !out.ok() {
1908 let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
1909 bail!(
1910 "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
1911 work in it.\n{}",
1912 path.display(),
1913 style::clip(text.trim(), 4000)
1914 );
1915 }
1916 Ok(())
1917 }
1918
1919 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1921 let branch = self.branch_for_issue(issue);
1922 let path = self.worktree_path(&format!("issue-{issue}"));
1923
1924 self.refuse_issue_branch_rebuild(issue, base)?;
1925 self.refuse_dirty_worktree(
1926 &path,
1927 &format!("worktree for issue #{issue}"),
1928 Disposable::BuildOutput,
1929 )?;
1930
1931 if !self.branch_deletion_is_safe(&branch)? {
1932 bail!(
1933 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1934 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1935 before retrying."
1936 );
1937 }
1938
1939 if !self.remove_worktree_at(&path)? {
1940 bail!(
1941 "the existing worktree for issue #{issue} could not be removed safely. Its \
1942 branch was kept."
1943 );
1944 }
1945 if !self.delete_branch_if_safe(&branch)? {
1946 bail!(
1947 "the existing branch {branch} changed or remained checked out while its \
1948 worktree was being rebuilt. It was kept."
1949 );
1950 }
1951
1952 if let Some(parent) = path.parent() {
1953 std::fs::create_dir_all(parent)
1954 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1955 }
1956
1957 let path_str = path.display().to_string();
1958 let remote_start = format!("origin/{base}");
1959 let created = self
1960 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1961 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1962
1963 created.map_err(|e| {
1966 spar_err!(
1967 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1968 and does `origin` exist?",
1969 e.last_line()
1970 )
1971 })?;
1972 self.record_branch(&branch, "issue", issue);
1973 self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
1974 Ok((path, branch))
1975 }
1976
1977 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1984 let branch = self.branch_for_issue(issue);
1985 let base_remote_ref = format!("refs/heads/{base}");
1986 let base_tracking_ref = format!("refs/remotes/origin/{base}");
1987 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1988 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1989 .map_err(|e| {
1990 spar_err!(
1991 "could not refresh origin/{base} before checking issue #{issue}: {}",
1992 e.last_line()
1993 )
1994 })?;
1995
1996 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1997 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1998 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1999 bail!(
2000 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
2001 pull request accounts for them. Rebuilding it would force push over that \
2002 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
2003 it, or delete it with `git push origin --delete {branch}` if the remote \
2004 branch is no longer needed."
2005 );
2006 }
2007 }
2008
2009 if self.fork_when_denied && self.origin_refuses_push() {
2012 match self.existing_fork() {
2013 Ok(Some(fork)) => {
2014 self.fork_branch_lease(&fork, &branch, base)?;
2015 }
2016 Ok(None) => {}
2017 Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
2018 }
2019 }
2020
2021 let local_ref = format!("refs/heads/{branch}");
2022 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2023 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
2024 let recorded_pr = self
2025 .known_branches()
2026 .get(&branch)
2027 .is_some_and(|record| record.kind == "pr");
2028 let preserved = ahead == 0
2029 || if recorded_pr {
2030 self.local_branch_is_preserved(&branch)?
2031 } else {
2032 self.pull_request_holds(&branch, &local_ref, base)
2033 };
2034 if !preserved {
2035 let listed = self
2036 .commit_lines(&self.root, &local_ref, base)
2037 .iter()
2038 .map(|line| format!(" {line}"))
2039 .collect::<Vec<_>>()
2040 .join("\n");
2041 bail!(
2042 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
2043 no pull request preserves them. Rebuilding it would delete the only copy.\n\
2044 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
2045 it, or delete it with `git branch -D {branch}` if it is stale."
2046 );
2047 }
2048 }
2049 Ok(())
2050 }
2051
2052 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2053 let live_ref = format!("refs/heads/{branch}");
2054 let tracking_ref = format!("refs/remotes/origin/{branch}");
2055 let listed = self
2056 .git(&["ls-remote", "--heads", "origin", &live_ref])
2057 .map_err(|e| {
2058 spar_err!(
2059 "could not verify whether origin/{branch} still exists: {}",
2060 e.last_line()
2061 )
2062 })?;
2063
2064 if remote_head_oid(&listed, &live_ref)?.is_some() {
2065 let refspec = format!("+{live_ref}:{tracking_ref}");
2066 self.git(&["fetch", "--no-tags", "origin", &refspec])
2067 .map_err(|e| {
2068 spar_err!(
2069 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2070 e.last_line()
2071 )
2072 })?;
2073 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2074 bail!("origin/{branch} was fetched but its tracking ref is missing");
2075 }
2076 return Ok(Some(tracking_ref));
2077 }
2078
2079 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2080 return Ok(None);
2081 }
2082 let expected = self
2083 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2084 .trim()
2085 .to_string();
2086 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2087 .map_err(|e| {
2088 spar_err!(
2089 "could not discard stale origin/{branch} tracking ref safely: {}",
2090 e.last_line()
2091 )
2092 })?;
2093 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2094 bail!(
2095 "origin/{branch} changed while its stale tracking ref was being removed. It was \
2096 kept."
2097 );
2098 }
2099 Ok(None)
2100 }
2101
2102 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2111 self.prs_for_branch(branch)
2112 .iter()
2113 .any(|pr| self.pr_head_holds(pr.number, refname, base))
2114 }
2115
2116 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2117 let head = format!("refs/spar/pr-head/{number}");
2118 let refspec = format!("+refs/pull/{number}/head:{head}");
2119 if self.git(&["fetch", "origin", &refspec]).is_err() {
2120 return false;
2121 }
2122 let held = self.commits_held_by(refname, base, &head);
2123 self.git_try(&["update-ref", "-d", &head]);
2124 held
2125 }
2126
2127 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2128 let argv = vec![
2129 "git".to_string(),
2130 "merge-base".to_string(),
2131 "--is-ancestor".to_string(),
2132 older.to_string(),
2133 newer.to_string(),
2134 ];
2135 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2136 match out.code {
2137 0 => Ok(true),
2138 1 => Ok(false),
2139 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2140 }
2141 }
2142
2143 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2144 let head = format!("refs/spar/pr-head/{number}");
2145 let refspec = format!("+refs/pull/{number}/head:{head}");
2146 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2147 spar_err!(
2148 "could not verify the immutable head of PR #{number}: {}",
2149 e.last_line()
2150 )
2151 })?;
2152 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2153 self.git_try(&["update-ref", "-d", &head]);
2154 held
2155 }
2156
2157 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2158 let text = self.gh(&[
2159 "pr",
2160 "list",
2161 "--head",
2162 branch,
2163 "--state",
2164 "all",
2165 "--json",
2166 "number,url,title",
2167 ])?;
2168 serde_json::from_str(text.trim())
2169 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2170 }
2171
2172 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2173 let branch_ref = format!("refs/heads/{branch}");
2174 if record.kind == "pr" {
2175 return self.pr_head_contains_checked(record.number, &branch_ref);
2176 }
2177 let prs = self.branch_prs_checked(branch)?;
2178 if prs.is_empty() {
2179 return Ok(false);
2180 }
2181 for pr in prs {
2182 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2183 return Ok(true);
2184 }
2185 }
2186 Ok(false)
2187 }
2188
2189 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2190 let local_ref = format!("refs/heads/{branch}");
2191 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2192 return Ok(true);
2193 }
2194 let oid = self
2195 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2196 .trim()
2197 .to_string();
2198 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2199 if !durable_tip {
2200 let remote_ref = format!("refs/heads/{branch}");
2201 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2202 durable_tip = remote.lines().any(|line| {
2203 line.split_whitespace()
2204 .next()
2205 .is_some_and(|remote_oid| remote_oid == oid)
2206 });
2207 }
2208 if !durable_tip {
2209 if let Some(record) = self.known_branches().get(branch) {
2210 durable_tip = self.branch_is_preserved_checked(branch, record)?;
2211 }
2212 }
2213 if !durable_tip {
2214 return Ok(false);
2215 }
2216 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2217 }
2218
2219 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2223 let local_ref = format!("refs/heads/{branch}");
2224 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2225 return Ok(true);
2226 }
2227 let expected = self
2228 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2229 .trim()
2230 .to_string();
2231 if !self.branch_deletion_is_safe(branch)? {
2232 return Ok(false);
2233 }
2234 let checked_out = self
2235 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2236 .lines()
2237 .any(|line| line == format!("branch {local_ref}"));
2238 if checked_out {
2239 return Ok(false);
2240 }
2241 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2242 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2243 }
2244
2245 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2246 let local_ref = review_ref(number);
2247 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2248 return Ok(true);
2249 }
2250 let oid = self
2251 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2252 .trim()
2253 .to_string();
2254 if !self.pr_head_contains_checked(number, &local_ref)? {
2255 return Ok(false);
2256 }
2257 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2258 }
2259
2260 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2261 let local_ref = review_ref(number);
2262 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2263 return Ok(true);
2264 }
2265 let expected = self
2266 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2267 .trim()
2268 .to_string();
2269 if !self.review_ref_deletion_is_safe(number)? {
2270 return Ok(false);
2271 }
2272 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2273 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2274 }
2275
2276 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2280 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2281 self.git_try(&["rev-list", "--count", &range, "--not", other])
2282 .trim()
2283 == "0"
2284 }
2285
2286 pub fn worktree_remove(&self, issue: i64) -> bool {
2287 let path = self.worktree_path(&format!("issue-{issue}"));
2288 match self.remove_worktree_at(&path) {
2289 Ok(removed) => removed,
2290 Err(error) => {
2291 logdim!(
2292 "kept {} because removal did not reach a confirmed quiet point: {}",
2293 path.display(),
2294 error.last_line()
2295 );
2296 false
2297 }
2298 }
2299 }
2300
2301 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2306 let wanted = std::fs::canonicalize(path)
2307 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2308 if wanted != path {
2313 return Ok(false);
2314 }
2315 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2316 let raw = PathBuf::from(value.trim());
2317 let joined = if raw.is_absolute() {
2318 raw
2319 } else {
2320 cwd.join(raw)
2321 };
2322 std::fs::canonicalize(&joined)
2323 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2324 };
2325 let expected =
2326 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2327 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2328 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2329 let expected = resolve(&self.root, &expected)?;
2330 let actual = resolve(path, &actual)?;
2331 let top = resolve(path, &top)?;
2332 Ok(expected == actual && top == wanted)
2333 }
2334
2335 fn remove_worktree_at_with_force(
2341 &self,
2342 path: &Path,
2343 force: bool,
2344 disposable: Disposable,
2345 ) -> Result<bool> {
2346 let existed = path.exists();
2347 if path.exists() {
2348 match self.worktree_belongs_to_repo(path) {
2349 Ok(true) => {}
2350 Ok(false) => {
2351 logdim!(
2352 "kept {} because it is not a worktree owned by this repository",
2353 path.display()
2354 );
2355 return Ok(false);
2356 }
2357 Err(e) => {
2358 logdim!(
2359 "kept {} because its worktree ownership could not be verified: {}",
2360 path.display(),
2361 e.last_line()
2362 );
2363 return Ok(false);
2364 }
2365 }
2366 if !force {
2367 match self.has_recoverable_work(path, disposable) {
2368 Ok(true) => {
2369 logdim!(
2370 "kept {} because it contains recoverable files or repository state",
2371 path.display()
2372 );
2373 return Ok(false);
2374 }
2375 Err(e) => {
2376 logdim!(
2377 "kept {} because its recoverable state could not be checked: {}",
2378 path.display(),
2379 e.last_line()
2380 );
2381 return Ok(false);
2382 }
2383 Ok(false) => {}
2384 }
2385 }
2386 }
2387 let path_str = path.display().to_string();
2393 let command_ok = if force || disposable == Disposable::ReviewOutput {
2394 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2395 } else {
2396 self.git_try_without_automation(&["worktree", "remove", &path_str])?
2397 };
2398 Ok((command_ok || !existed) && !path.exists())
2399 }
2400
2401 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2402 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2403 }
2404
2405 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2407 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2408 }
2409
2410 fn remove_worktree_at_force(&self, path: &Path) -> bool {
2412 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2413 Ok(removed) => removed,
2414 Err(error) => {
2415 logdim!(
2416 "kept {} because removal did not reach a confirmed quiet point: {}",
2417 path.display(),
2418 error.last_line()
2419 );
2420 false
2421 }
2422 }
2423 }
2424
2425 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2430 if path.exists() && !self.worktree_belongs_to_repo(path)? {
2431 bail!(
2432 "{} is not a worktree owned by this repository, so it was kept",
2433 path.display()
2434 );
2435 }
2436 if path.exists() && self.has_recoverable_work(path, disposable)? {
2437 bail!(
2438 "the verified worktree at {} contains recoverable files or repository state. It \
2439 was kept.",
2440 path.display()
2441 );
2442 }
2443 let path_str = path.display().to_string();
2444 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2445 .map_err(|e| {
2446 e.with_message(format!(
2447 "could not remove the verified worktree at {}: {}. It was kept.",
2448 path.display(),
2449 e.last_line()
2450 ))
2451 })?;
2452 Ok(!path.exists())
2453 }
2454
2455 fn refuse_dirty_worktree(
2456 &self,
2457 path: &Path,
2458 label: &str,
2459 disposable: Disposable,
2460 ) -> Result<()> {
2461 if !path.is_dir() {
2462 return Ok(());
2463 }
2464 let has_files = std::fs::read_dir(path)
2465 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2466 .next()
2467 .is_some();
2468 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2469 spar_err!(
2470 "could not verify whether the existing {label} at {} belongs to this repository, \
2471 so it was kept: {}",
2472 path.display(),
2473 e.last_line()
2474 )
2475 })?;
2476 if !owned {
2477 if has_files {
2478 bail!(
2479 "the existing {label} at {} is not a worktree owned by \
2480 this repository. Refusing to remove it.",
2481 path.display()
2482 );
2483 }
2484 return Ok(());
2485 }
2486 if !path.join(".git").exists() {
2487 if has_files {
2488 bail!(
2489 "the existing {label} at {} is not a readable Git worktree and is not empty. \
2490 Refusing to remove it.",
2491 path.display()
2492 );
2493 }
2494 return Ok(());
2495 }
2496 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2497 spar_err!(
2498 "could not verify whether the existing {label} at {} is clean, so it was kept: \
2499 {}",
2500 path.display(),
2501 e.last_line()
2502 )
2503 })?;
2504 if dirty {
2505 bail!(
2506 "the existing {label} contains uncommitted changes or ignored files at {}. \
2507 Rebuilding it would delete those files.\nCommit or recover them before running this \
2508 command again, or use `spar clean --all` if they are not needed.",
2509 path.display()
2510 );
2511 }
2512 Ok(())
2513 }
2514
2515 pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2518 let head = pr.head_ref_name.clone();
2519 if head.trim().is_empty() {
2520 bail!("PR #{} has no head branch to check out", pr.number);
2521 }
2522 let path = self.worktree_path(&format!("pr-{}", pr.number));
2523 let local = self.branch_for_pr(pr.number);
2524
2525 let unfetched = |e: crate::error::SparError| {
2526 spar_err!(
2527 "could not fetch the branch behind PR #{}: {}",
2528 pr.number,
2529 e.last_line()
2530 )
2531 };
2532 let (start, start_ref, shown) = match target {
2535 PushTarget::Origin => {
2536 self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2537 let start = format!("origin/{head}");
2538 (start.clone(), format!("refs/remotes/origin/{head}"), start)
2539 }
2540 PushTarget::Fork(fork) => {
2543 let start_ref = format!("refs/spar/fork/{head}");
2544 let refspec = format!("+refs/heads/{head}:{start_ref}");
2545 self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2546 .map_err(unfetched)?;
2547 (start_ref.clone(), start_ref, target.describe(&head))
2548 }
2549 };
2550 let local_ref = format!("refs/heads/{local}");
2551 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2552 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2553 if unpushed > 0 {
2554 bail!(
2555 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2556 on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2557 {} and push or recover those commits before running this command again.",
2558 pr.number,
2559 path.display()
2560 );
2561 }
2562 }
2563 self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2564 self.refuse_dirty_worktree(
2565 &path,
2566 &format!("worktree for PR #{}", pr.number),
2567 Disposable::BuildOutput,
2568 )?;
2569 if !self.branch_deletion_is_safe(&local)? {
2570 bail!(
2571 "the existing branch {local} has a tip or reflog-only commit that no surviving \
2572 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2573 before retrying."
2574 );
2575 }
2576 if !self.remove_worktree_at(&path)? {
2577 bail!(
2578 "the existing worktree for PR #{} could not be removed safely. Its branch was \
2579 kept.",
2580 pr.number
2581 );
2582 }
2583 if !self.delete_branch_if_safe(&local)? {
2584 bail!(
2585 "the existing branch {local} changed or remained checked out while the PR \
2586 worktree was being rebuilt. It was kept."
2587 );
2588 }
2589
2590 let path_str = path.display().to_string();
2591 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2592 self.record_branch(&local, "pr", pr.number);
2593 self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2594 Ok((path, head))
2595 }
2596
2597 fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2600 if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2601 return Ok(());
2602 }
2603 let current =
2604 self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2605 if current.trim() != branch
2606 || self.has_uncommitted_changes(path)?
2607 || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2608 {
2609 return Ok(());
2610 }
2611 static NEXT: AtomicUsize = AtomicUsize::new(0);
2612 let (name, retained) = loop {
2613 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2614 let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2615 let retained = self.worktree_path(&name);
2616 if !retained
2617 .try_exists()
2618 .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2619 && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2620 {
2621 break (name, retained);
2622 }
2623 };
2624 self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2627 if let Err(error) = self.git_at_without_automation(
2628 &self.root,
2629 &[
2630 "worktree",
2631 "move",
2632 &path.display().to_string(),
2633 &retained.display().to_string(),
2634 ],
2635 ) {
2636 let rollback =
2639 self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2640 let detail = rollback
2641 .err()
2642 .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2643 .unwrap_or_default();
2644 bail!(
2645 "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2646 retained.display(),
2647 error.last_line()
2648 );
2649 }
2650 logdim!(
2651 "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2652 retained.display()
2653 );
2654 Ok(())
2655 }
2656
2657 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2667 let path = self.worktree_path(&format!("review-{number}"));
2668 let local_ref = review_ref(number);
2669 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2670
2671 self.refuse_review_worktree_changes(number)?;
2672
2673 if let Some(parent) = path.parent() {
2674 std::fs::create_dir_all(parent)
2675 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2676 }
2677 if !self.remove_review_worktree_at(&path)? {
2682 bail!(
2683 "the existing review worktree for PR #{number} could not be removed safely. Its \
2684 reference was kept."
2685 );
2686 }
2687
2688 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2689 spar_err!(
2690 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2691 every pull request, so this usually means the number is wrong or `origin` does \
2692 not point at the repository the PR is on.",
2693 e.last_line()
2694 )
2695 })?;
2696
2697 let path_str = path.display().to_string();
2698 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2699 self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2700 Ok(path)
2701 }
2702
2703 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2704 let path = self.worktree_path(&format!("review-{number}"));
2705 if !path.is_dir() {
2706 return Ok(());
2707 }
2708 let local_ref = review_ref(number);
2709 if !self.worktree_belongs_to_repo(&path)? {
2710 return Ok(());
2711 }
2712 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2713 bail!(
2714 "the existing review worktree for PR #{number} has no recorded head at \
2715 {local_ref}. Refusing to rebuild {}.",
2716 path.display()
2717 );
2718 }
2719 let worktree_head = self.head_oid_checked(&path)?;
2720 let recorded_head = self
2721 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2722 .trim()
2723 .to_string();
2724 if worktree_head != recorded_head {
2725 bail!(
2726 "the existing review worktree for PR #{number} has a local commit that is not on \
2727 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2728 {} before retrying.",
2729 path.display()
2730 );
2731 }
2732 self.report_recovery_markers(&path);
2733 self.refuse_dirty_worktree(
2734 &path,
2735 &format!("review worktree for PR #{number}"),
2736 Disposable::ReviewOutput,
2737 )?;
2738 Ok(())
2739 }
2740
2741 fn report_recovery_markers(&self, path: &Path) {
2749 let Ok(entries) = std::fs::read_dir(path) else {
2750 return;
2751 };
2752 for entry in entries.flatten() {
2753 let name = entry.file_name();
2754 let Some(name) = name.to_str() else { continue };
2755 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2756 continue;
2757 }
2758 match std::fs::read_to_string(entry.path()) {
2759 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2760 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2761 }
2762 }
2763 }
2764
2765 pub fn read_checkout(&self, label: &str) -> Result<ReadCheckout<'_>> {
2771 let shared = || ReadCheckout {
2772 repo: self,
2773 path: self.root.clone(),
2774 checkpoint: None,
2775 };
2776 if !self.isolate_reads {
2777 return Ok(shared());
2778 }
2779 let Some(head) = resolve_optional_commit(&self.root, "HEAD")? else {
2780 return Ok(shared());
2781 };
2782 static NEXT: AtomicUsize = AtomicUsize::new(0);
2783 let path = loop {
2784 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2785 let path = self.worktree_path(&format!(
2786 "{READ_CHECKOUT_PREFIX}{}-{serial}",
2787 std::process::id()
2788 ));
2789 if !path
2790 .try_exists()
2791 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2792 {
2793 break path;
2794 }
2795 };
2796 if let Some(parent) = path.parent() {
2797 std::fs::create_dir_all(parent)
2798 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2799 }
2800 let path_str = path.display().to_string();
2801 self.git(&["worktree", "add", "--detach", &path_str, &head])
2802 .map_err(|e| {
2803 spar_err!(
2804 "could not check HEAD out for the {label}: {}",
2805 e.last_line()
2806 )
2807 })?;
2808 let ready = self
2809 .set_up_worktree(&path, &format!("read-only checkout for the {label}"))
2810 .and_then(|()| self.worktree_checkpoint(&path));
2811 match ready {
2812 Ok(checkpoint) => Ok(ReadCheckout {
2813 repo: self,
2814 path,
2815 checkpoint: Some(checkpoint),
2816 }),
2817 Err(e) => {
2818 self.discard_read_checkout(&path);
2820 Err(e)
2821 }
2822 }
2823 }
2824
2825 fn release_read_checkout(&self, path: &Path, checkpoint: &WorktreeCheckpoint) {
2832 if self.keep_worktrees {
2833 logdim!("kept the read-only checkout at {}", path.display());
2834 return;
2835 }
2836 if has_recovery_marker(path) {
2837 logdim!(
2838 "kept the read-only checkout at {} for recovery",
2839 path.display()
2840 );
2841 return;
2842 }
2843 if let Err(e) = self.require_unchanged_worktree(path, checkpoint, "read-only checkout") {
2844 logwarn!("{e}");
2845 return;
2846 }
2847 self.discard_read_checkout(path);
2848 }
2849
2850 fn discard_read_checkout(&self, path: &Path) {
2851 match self.remove_worktree_at_checked(path, Disposable::ReviewOutput) {
2852 Ok(true) => {}
2853 Ok(false) => logdim!(
2854 "kept the read-only checkout at {} because it was still there after removal",
2855 path.display()
2856 ),
2857 Err(e) => logdim!(
2858 "kept the read-only checkout at {}: {}",
2859 path.display(),
2860 e.last_line()
2861 ),
2862 }
2863 }
2864
2865 pub fn worktree_for_split(
2878 &self,
2879 parent: i64,
2880 index: usize,
2881 start: &str,
2882 ) -> Result<(PathBuf, String)> {
2883 let slot = self.free_split_slot(parent, index)?;
2884 let branch = format!("{}{slot}", self.branch_prefix);
2885 let path = self.worktree_path(&slot);
2886
2887 if let Some(dir) = path.parent() {
2888 std::fs::create_dir_all(dir)
2889 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2890 }
2891 self.refuse_dirty_worktree(
2892 &path,
2893 &format!("worktree for part {index} of PR #{parent}"),
2894 Disposable::BuildOutput,
2895 )?;
2896 if !self.remove_worktree_at(&path)? {
2900 bail!(
2901 "the existing worktree for part {index} of PR #{parent} could not be removed \
2902 safely. No branch was created."
2903 );
2904 }
2905
2906 let path_str = path.display().to_string();
2907 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2908 .map_err(|e| {
2909 spar_err!(
2910 "could not create a worktree for part {index} of #{parent}. {}",
2911 e.last_line()
2912 )
2913 })?;
2914 self.record_branch(&branch, "split", parent);
2917 self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
2918 Ok((path, branch))
2919 }
2920
2921 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2928 for attempt in 1..=SPLIT_SLOTS {
2929 let slot = split_slot(parent, index, attempt);
2930 let branch = format!("{}{slot}", self.branch_prefix);
2931 self.git_try(&["fetch", "origin", &branch]);
2932 if !self.rev_exists(&self.root, &branch)
2933 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2934 {
2935 return Ok(slot);
2936 }
2937 }
2938 bail!(
2939 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2940 names are all taken. Inspect the existing branches and child pull requests. Finish \
2941 recording the earlier split, or remove every retained local worktree and branch, \
2942 child pull request, and remote split branch before starting over.",
2943 self.branch_for_split(parent, index)
2944 )
2945 }
2946
2947 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2953 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2954 Ok(!self
2955 .git(&["ls-remote", "--heads", "origin", &pattern])?
2956 .trim()
2957 .is_empty())
2958 }
2959
2960 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2967 match self.branch_deletion_is_safe(branch) {
2968 Ok(true) => {}
2969 Ok(false) => {
2970 logdim!(
2971 "kept {branch} and {} because no surviving ref preserves its tip",
2972 dir.display()
2973 );
2974 return;
2975 }
2976 Err(error) => {
2977 logdim!(
2978 "kept {branch} and {} because preservation could not be verified: {}",
2979 dir.display(),
2980 error.last_line()
2981 );
2982 return;
2983 }
2984 }
2985 match self.remove_worktree_at(dir) {
2986 Ok(true) => match self.delete_branch_if_safe(branch) {
2987 Ok(true) => self.forget_branch(branch),
2988 Ok(false) => {
2989 logdim!("kept {branch} because its tip or reflog changed before deletion")
2990 }
2991 Err(error) => logdim!(
2992 "kept {branch} because deletion safety could not be rechecked: {}",
2993 error.last_line()
2994 ),
2995 },
2996 Ok(false) => {}
2997 Err(error) => logdim!(
2998 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2999 dir.display(),
3000 error.last_line()
3001 ),
3002 }
3003 }
3004
3005 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
3011 let record = self.known_branches().get(branch).cloned();
3012 if record.is_none_or(|record| record.kind != "split") {
3013 logdim!("kept {branch} because no split branch record proves ownership");
3014 return false;
3015 }
3016 let local_ref = format!("refs/heads/{branch}");
3017 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
3018 Ok(value) => value.trim().to_string(),
3019 Err(error) => {
3020 logdim!(
3021 "kept {branch} because its tip could not be checked: {}",
3022 error.last_line()
3023 );
3024 return false;
3025 }
3026 };
3027 if expected != disposable_head {
3028 logdim!("kept {branch} because it moved beyond the disposable slice");
3029 return false;
3030 }
3031 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
3032 Ok(true) => {}
3033 Ok(false) => {
3034 logdim!(
3035 "kept {branch} because its reflog contains work outside the disposable slice"
3036 );
3037 return false;
3038 }
3039 Err(error) => {
3040 logdim!(
3041 "kept {branch} because its reflog could not be checked: {}",
3042 error.last_line()
3043 );
3044 return false;
3045 }
3046 }
3047 match self.head_oid_checked(dir) {
3048 Ok(head) if head == disposable_head => {}
3049 Ok(_) => {
3050 logdim!(
3051 "kept {branch} and {} because the worktree moved beyond the disposable slice",
3052 dir.display()
3053 );
3054 return false;
3055 }
3056 Err(error) => {
3057 logdim!(
3058 "kept {branch} and {} because its head could not be checked: {}",
3059 dir.display(),
3060 error.last_line()
3061 );
3062 return false;
3063 }
3064 }
3065 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
3066 Ok(true) => {}
3067 Ok(false) => return false,
3068 Err(error) => {
3069 logdim!(
3070 "kept {branch} and {} because the disposable slice could not be verified: {}",
3071 dir.display(),
3072 error.last_line()
3073 );
3074 return false;
3075 }
3076 }
3077 if let Err(error) =
3078 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
3079 {
3080 logdim!(
3081 "kept {branch} because its exact disposable tip could not be deleted: {}",
3082 error.last_line()
3083 );
3084 return false;
3085 }
3086 match self.exact_ref_exists_checked(&self.root, &local_ref) {
3087 Ok(false) => {
3088 self.forget_branch(branch);
3089 true
3090 }
3091 Ok(true) => {
3092 logdim!("kept {branch} because its ref still exists after deletion");
3093 false
3094 }
3095 Err(error) => {
3096 logdim!(
3097 "kept the branch record for {branch} because deletion could not be verified: {}",
3098 error.last_line()
3099 );
3100 false
3101 }
3102 }
3103 }
3104
3105 pub fn release_review_worktree(&self, number: i64) {
3106 let path = self.worktree_path(&format!("review-{number}"));
3107 match self.review_ref_deletion_is_safe(number) {
3108 Ok(true) => {}
3109 Ok(false) => {
3110 logdim!(
3111 "kept {} because no surviving ref preserves its review history",
3112 path.display()
3113 );
3114 return;
3115 }
3116 Err(error) => {
3117 logdim!(
3118 "kept {} because review history could not be verified: {}",
3119 path.display(),
3120 error.last_line()
3121 );
3122 return;
3123 }
3124 }
3125 match self.remove_worktree_at(&path) {
3126 Ok(true) => match self.delete_review_ref_if_safe(number) {
3127 Ok(true) => {}
3128 Ok(false) => logdim!(
3129 "kept {} because its review history changed before deletion",
3130 review_ref(number)
3131 ),
3132 Err(error) => logdim!(
3133 "kept {} because deletion safety could not be rechecked: {}",
3134 review_ref(number),
3135 error.last_line()
3136 ),
3137 },
3138 Ok(false) => {}
3139 Err(error) => logdim!(
3140 "kept {} because removal did not reach a confirmed quiet point: {}",
3141 path.display(),
3142 error.last_line()
3143 ),
3144 }
3145 }
3146
3147 pub(crate) fn release_review_worktree_checked(
3150 &self,
3151 number: i64,
3152 checkpoint: &WorktreeCheckpoint,
3153 ) -> Result<()> {
3154 let path = self.worktree_path(&format!("review-{number}"));
3155 self.require_unchanged_worktree(
3156 &path,
3157 checkpoint,
3158 &format!("review worktree for PR #{number}"),
3159 )?;
3160 if !self.review_ref_deletion_is_safe(number)? {
3161 bail!(
3162 "the review reference for PR #{number} has reflog-only recovery history. The \
3163 worktree and reference were kept."
3164 );
3165 }
3166 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3167 bail!(
3168 "the verified review worktree at {} could not be removed, so its reference was \
3169 kept",
3170 path.display()
3171 );
3172 }
3173 if !self.delete_review_ref_if_safe(number)? {
3174 bail!(
3175 "the review reference for PR #{number} changed before deletion. The reference was \
3176 kept."
3177 );
3178 }
3179 Ok(())
3180 }
3181
3182 pub fn release_pr_worktree(&self, number: i64) -> bool {
3183 let path = self.worktree_path(&format!("pr-{number}"));
3184 let local = self.branch_for_pr(number);
3185 match self.branch_deletion_is_safe(&local) {
3186 Ok(true) => {}
3187 Ok(false) => {
3188 logdim!(
3189 "kept {local} and {} because no surviving ref preserves its tip",
3190 path.display()
3191 );
3192 return false;
3193 }
3194 Err(error) => {
3195 logdim!(
3196 "kept {local} and {} because preservation could not be verified: {}",
3197 path.display(),
3198 error.last_line()
3199 );
3200 return false;
3201 }
3202 }
3203 match self.remove_worktree_at(&path) {
3204 Ok(true) => match self.delete_branch_if_safe(&local) {
3205 Ok(true) => {
3206 self.forget_branch(&local);
3207 true
3208 }
3209 Ok(false) => {
3210 logdim!("kept {local} because its tip or reflog changed before deletion");
3211 false
3212 }
3213 Err(error) => {
3214 logdim!(
3215 "kept {local} because deletion safety could not be rechecked: {}",
3216 error.last_line()
3217 );
3218 false
3219 }
3220 },
3221 Ok(false) => false,
3222 Err(error) => {
3223 logdim!(
3224 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3225 path.display(),
3226 error.last_line()
3227 );
3228 false
3229 }
3230 }
3231 }
3232
3233 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3244 let remote = format!("origin/{base}");
3245 if self.rev_exists(cwd, &remote) {
3246 return remote;
3247 }
3248 if self.rev_exists(cwd, base) {
3249 logdim!("origin/{base} does not resolve, comparing against local {base}");
3250 return base.to_string();
3251 }
3252 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3253 remote
3254 }
3255
3256 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3257 let spec = format!("{refname}^{{commit}}");
3258 !self
3259 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3260 .trim()
3261 .is_empty()
3262 }
3263
3264 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3265 let range = format!("{}..HEAD", self.base_ref(cwd, base));
3266 !self
3267 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3268 .trim()
3269 .is_empty()
3270 }
3271
3272 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3273 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3274 Ok(found.lines().any(|line| line.trim() == refname))
3275 }
3276
3277 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3278 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3279 count.trim().parse::<usize>().map_err(|e| {
3280 spar_err!(
3281 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3282 count.trim()
3283 )
3284 })
3285 }
3286
3287 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3288 let remote = format!("refs/remotes/origin/{base}");
3289 if self.exact_ref_exists_checked(cwd, &remote)? {
3290 return Ok(remote);
3291 }
3292 let local = format!("refs/heads/{base}");
3293 if self.exact_ref_exists_checked(cwd, &local)? {
3294 return Ok(local);
3295 }
3296 bail!("neither origin/{base} nor local branch {base} resolves")
3297 }
3298
3299 pub(crate) fn commit_count_checked(
3300 &self,
3301 cwd: &Path,
3302 refname: &str,
3303 base: &str,
3304 ) -> Result<usize> {
3305 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3306 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3307 count.trim().parse::<usize>().map_err(|e| {
3308 spar_err!(
3309 "git returned an invalid commit count for {range}: {:?} ({e})",
3310 count.trim()
3311 )
3312 })
3313 }
3314
3315 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3316 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3317 }
3318
3319 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3320 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3321 let head = head.trim().to_string();
3322 if head.is_empty() {
3323 bail!("git returned an empty HEAD for {}", cwd.display());
3324 }
3325 Ok(head)
3326 }
3327
3328 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3333 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3334 self.local_branch_is_preserved(branch.trim())
3335 }
3336
3337 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3340 let known = self.known_branches();
3341 let Some(record) = known.get(branch) else {
3342 return Ok(false);
3343 };
3344 self.branch_is_preserved_checked(branch, record)
3345 }
3346
3347 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3349 has_uncommitted_work(cwd)
3350 }
3351
3352 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3355 repository_has_recoverable_work_with(cwd, true, disposable)
3356 }
3357
3358 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3360 let attributes = attribute_state(cwd)?;
3361 Ok(WorktreeBaseline {
3362 attributes,
3363 ignored_untracked: ignored_untracked_state(cwd)?,
3364 git_state: safe_git_state(cwd)?,
3365 })
3366 }
3367
3368 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3371 let attributes = attribute_state(cwd)?;
3372 Ok(WorktreeCheckpoint {
3373 path: std::fs::canonicalize(cwd)
3374 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3375 attributes,
3376 git_state: safe_git_state(cwd)?,
3377 ignored_untracked: ignored_untracked_state(cwd)?,
3378 })
3379 }
3380
3381 pub(crate) fn require_unchanged_worktree(
3384 &self,
3385 cwd: &Path,
3386 checkpoint: &WorktreeCheckpoint,
3387 label: &str,
3388 ) -> Result<()> {
3389 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3390 crate::error::SparError::uncertain_write(format!(
3391 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3392 cwd.display()
3393 ))
3394 })?;
3395 if resolved != checkpoint.path {
3396 return Err(uncertain_worktree_change(
3397 cwd,
3398 format!(
3399 "the {label} moved from {} to {} during inspection. It was kept.",
3400 checkpoint.path.display(),
3401 resolved.display()
3402 ),
3403 ));
3404 }
3405 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3406 uncertain_worktree_change(
3407 cwd,
3408 format!(
3409 "could not verify attribute files in the {label} at {}: {}. It was kept.",
3410 cwd.display(),
3411 e.last_line()
3412 ),
3413 )
3414 })?;
3415 if attributes != checkpoint.attributes {
3416 return Err(uncertain_worktree_change(
3417 cwd,
3418 format!(
3419 "attribute files in the {label} at {} changed during inspection. It was \
3420 kept for recovery.",
3421 cwd.display()
3422 ),
3423 ));
3424 }
3425 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3426 uncertain_worktree_change(
3427 cwd,
3428 format!(
3429 "could not verify the Git state of the {label} at {}: {}. It was kept.",
3430 cwd.display(),
3431 e.last_line()
3432 ),
3433 )
3434 })?;
3435 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3436 uncertain_worktree_change(
3437 cwd,
3438 format!(
3439 "could not verify untracked files in the {label} at {}: {}. It was kept.",
3440 cwd.display(),
3441 e.last_line()
3442 ),
3443 )
3444 })?;
3445 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3446 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3450 let tracked = checkpoint.git_state.describe_difference(&git_state);
3451 let untracked = (!leavings.condemning.is_empty()).then(|| {
3452 format!(
3453 "untracked file(s) changed: {}",
3454 list_paths(&leavings.condemning)
3455 )
3456 });
3457 let Some(reason) = join_reasons(tracked, untracked) else {
3458 return Ok(());
3459 };
3460 Err(uncertain_worktree_change(
3461 cwd,
3462 format!(
3463 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3464 for recovery.",
3465 cwd.display()
3466 ),
3467 ))
3468 }
3469
3470 pub(crate) fn refuse_new_ignored_files(
3474 &self,
3475 cwd: &Path,
3476 baseline: &WorktreeBaseline,
3477 ) -> Result<()> {
3478 self.allow_ignored_files(cwd, baseline).map(drop)
3479 }
3480
3481 pub(crate) fn refuse_failed_edit_leavings(
3483 &self,
3484 cwd: &Path,
3485 baseline: &WorktreeBaseline,
3486 ) -> Result<()> {
3487 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3488 .map(drop)
3489 }
3490
3491 pub(crate) fn refuse_read_only_leavings(
3493 &self,
3494 cwd: &Path,
3495 baseline: &WorktreeBaseline,
3496 ) -> Result<()> {
3497 self.refuse_changed_attributes(cwd, baseline)?;
3498 let after = ignored_untracked_state(cwd).map_err(|e| {
3499 uncertain_worktree_change(
3500 cwd,
3501 format!(
3502 "could not verify untracked files in {} after a read-only call: {}. The \
3503 worktree was kept for recovery.",
3504 cwd.display(),
3505 e.last_line()
3506 ),
3507 )
3508 })?;
3509 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3510 if leavings.condemning.is_empty() {
3511 return Ok(());
3512 }
3513 Err(uncertain_worktree_change(
3514 cwd,
3515 format!(
3516 "the read-only call left untracked file(s) in {} that the project does not \
3517 ignore: {}. The worktree was kept for recovery.",
3518 cwd.display(),
3519 list_paths(&leavings.condemning)
3520 ),
3521 ))
3522 }
3523
3524 fn allow_ignored_files(
3527 &self,
3528 cwd: &Path,
3529 baseline: &WorktreeBaseline,
3530 ) -> Result<AllowedArtifacts> {
3531 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3532 }
3533
3534 fn check_new_ignored_files(
3535 &self,
3536 cwd: &Path,
3537 baseline: &WorktreeBaseline,
3538 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3539 ) -> Result<AllowedArtifacts> {
3540 self.refuse_changed_attributes(cwd, baseline)?;
3541 let after = ignored_untracked_state(cwd).map_err(|e| {
3542 uncertain_worktree_change(
3543 cwd,
3544 format!(
3545 "could not verify untracked files in {} after editing: {}. The worktree was \
3546 kept for recovery.",
3547 cwd.display(),
3548 e.last_line()
3549 ),
3550 )
3551 })?;
3552 let changed = baseline.ignored_untracked.changed_paths(&after);
3553 if changed.is_empty() {
3554 return Ok(AllowedArtifacts::default());
3555 }
3556 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3557 .into_iter()
3558 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3559 if changed.is_empty() {
3560 return Ok(AllowedArtifacts::split(ignored, &after));
3561 }
3562 let listed = list_paths(&changed);
3563 Err(uncertain_worktree_change(
3564 cwd,
3565 format!(
3566 "the editing call created or changed untracked or ignored file(s) in {} that \
3567 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3568 recovery.",
3569 cwd.display()
3570 ),
3571 ))
3572 }
3573
3574 pub(crate) fn refuse_changed_existing_untracked(
3579 &self,
3580 cwd: &Path,
3581 baseline: &WorktreeBaseline,
3582 ) -> Result<()> {
3583 self.check_changed_existing_untracked(cwd, baseline)
3584 .map(drop)
3585 }
3586
3587 fn allow_changed_ignored_artifacts(
3588 &self,
3589 cwd: &Path,
3590 baseline: &WorktreeBaseline,
3591 ) -> Result<AllowedArtifacts> {
3592 self.check_changed_existing_untracked(cwd, baseline)
3593 }
3594
3595 fn check_changed_existing_untracked(
3596 &self,
3597 cwd: &Path,
3598 baseline: &WorktreeBaseline,
3599 ) -> Result<AllowedArtifacts> {
3600 self.refuse_changed_attributes(cwd, baseline)?;
3601 let after = ignored_untracked_state(cwd).map_err(|e| {
3602 uncertain_worktree_change(
3603 cwd,
3604 format!(
3605 "could not verify existing untracked files in {} after editing: {}. The \
3606 worktree was kept for recovery.",
3607 cwd.display(),
3608 e.last_line()
3609 ),
3610 )
3611 })?;
3612 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3613 if changed.is_empty() {
3614 return Ok(AllowedArtifacts::default());
3615 }
3616 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3617 .into_iter()
3618 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3619 if changed.is_empty() {
3620 return Ok(AllowedArtifacts::split(ignored, &after));
3621 }
3622 let listed = list_paths(&changed);
3623 Err(uncertain_worktree_change(
3624 cwd,
3625 format!(
3626 "the editing call changed or deleted existing untracked file(s) in {}: \
3627 {listed}. The worktree was kept for recovery.",
3628 cwd.display()
3629 ),
3630 ))
3631 }
3632
3633 pub(crate) fn refuse_unrepresented_tracked_changes(
3640 &self,
3641 cwd: &Path,
3642 baseline: &WorktreeBaseline,
3643 ) -> Result<()> {
3644 self.refuse_changed_attributes(cwd, baseline)?;
3645 let after = safe_git_state(cwd).map_err(|e| {
3646 uncertain_worktree_change(
3647 cwd,
3648 format!(
3649 "could not verify tracked files in {} after editing: {}. The worktree was \
3650 kept for recovery.",
3651 cwd.display(),
3652 e.last_line()
3653 ),
3654 )
3655 })?;
3656 let mut changed = Vec::new();
3657 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3658 uncertain_worktree_change(
3659 cwd,
3660 format!(
3661 "could not record untracked files before verifying transformed content in {}: \
3662 {}. The worktree was kept for recovery.",
3663 cwd.display(),
3664 e.last_line()
3665 ),
3666 )
3667 })?;
3668 let mut filter_was_run = false;
3669 let mut filter_problem = None;
3670 let mut repositories: BTreeSet<PathBuf> =
3671 baseline.git_state.repositories.keys().cloned().collect();
3672 repositories.extend(after.repositories.keys().cloned());
3673 'repositories: for repository_path in repositories {
3674 let before_repository = baseline.git_state.repositories.get(&repository_path);
3675 let after_repository = after.repositories.get(&repository_path);
3676 if before_repository.is_none() || after_repository.is_none() {
3677 changed.push(repository_path.clone());
3678 continue;
3679 }
3680 if before_repository.map(|repository| &repository.gitlinks)
3681 != after_repository.map(|repository| &repository.gitlinks)
3682 {
3683 changed.push(repository_path.join("<gitlinks>"));
3684 }
3685 let mut paths = BTreeSet::new();
3686 if let Some(repository) = before_repository {
3687 paths.extend(repository.tracked.keys().cloned());
3688 }
3689 if let Some(repository) = after_repository {
3690 paths.extend(repository.tracked.keys().cloned());
3691 }
3692 for path in paths {
3693 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3694 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3695 let worktree_changed =
3696 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3697 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3698 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3699 if !worktree_changed {
3700 continue;
3701 }
3702 if !index_changed {
3703 changed.push(repository_path.join(&path));
3704 continue;
3705 }
3706 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3707 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3708 let Some(current_entry) = current else {
3709 continue;
3710 };
3711 let Some(current_worktree) = current_worktree else {
3712 continue;
3713 };
3714 let content_changed =
3715 before_worktree.map(|file| file.content) != Some(current_worktree.content);
3716 let mode_changed = before_worktree.map(|file| file.mode.as_str())
3717 != Some(current_worktree.mode.as_str());
3718 let repository = cwd.join(&repository_path);
3719 let represented_content = if content_changed {
3720 filter_was_run = true;
3721 let result =
3722 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
3723 self.refuse_changed_attributes(cwd, baseline)?;
3724 match result {
3725 Ok(expected) => expected == current_worktree.content,
3726 Err(error) => {
3727 filter_problem = Some(format!(
3728 "could not verify transformed content for {:?}: {}",
3729 repository_path.join(&path),
3730 error.last_line()
3731 ));
3732 false
3733 }
3734 }
3735 } else {
3736 true
3737 };
3738 let represented_mode =
3739 !mode_changed || current_worktree.mode == current_entry.index_mode;
3740 if !represented_content || !represented_mode {
3741 changed.push(repository_path.join(&path));
3742 }
3743 if filter_problem.is_some() {
3744 break 'repositories;
3745 }
3746 }
3747 }
3748 if filter_was_run {
3749 self.refuse_changed_attributes(cwd, baseline)?;
3750 let verified = safe_git_state(cwd).map_err(|e| {
3751 uncertain_worktree_change(
3752 cwd,
3753 format!(
3754 "could not recheck tracked files after verifying transformed content in \
3755 {}: {}. The worktree was kept for recovery.",
3756 cwd.display(),
3757 e.last_line()
3758 ),
3759 )
3760 })?;
3761 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3762 uncertain_worktree_change(
3763 cwd,
3764 format!(
3765 "could not recheck untracked files after verifying transformed content \
3766 in {}: {}. The worktree was kept for recovery.",
3767 cwd.display(),
3768 e.last_line()
3769 ),
3770 )
3771 })?;
3772 if verified != after
3773 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3774 {
3775 return Err(uncertain_worktree_change(
3776 cwd,
3777 "a content filter changed the worktree while SPAR verified the managed \
3778 commit. The worktree was kept for recovery.",
3779 ));
3780 }
3781 self.refuse_changed_existing_untracked(cwd, baseline)?;
3782 }
3783 if let Some(problem) = filter_problem {
3784 return Err(uncertain_worktree_change(
3785 cwd,
3786 format!("{problem}. The worktree was kept for recovery."),
3787 ));
3788 }
3789 if changed.is_empty() {
3790 return Ok(());
3791 }
3792 let listed = list_paths(&changed);
3793 Err(uncertain_worktree_change(
3794 cwd,
3795 format!(
3796 "the editing call changed tracked working-file bytes, modes, repositories, or \
3797 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3798 recovery."
3799 ),
3800 ))
3801 }
3802
3803 pub(crate) fn refuse_changed_attributes(
3804 &self,
3805 cwd: &Path,
3806 baseline: &WorktreeBaseline,
3807 ) -> Result<()> {
3808 let after = attribute_state(cwd).map_err(|e| {
3809 uncertain_worktree_change(
3810 cwd,
3811 format!(
3812 "could not verify attribute files in {} after editing: {}. The worktree was \
3813 kept for recovery.",
3814 cwd.display(),
3815 e.last_line()
3816 ),
3817 )
3818 })?;
3819 if after == baseline.attributes {
3820 return Ok(());
3821 }
3822 Err(uncertain_worktree_change(
3823 cwd,
3824 format!(
3825 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3826 refused to run a Git operation that could select a new external filter.",
3827 cwd.display()
3828 ),
3829 ))
3830 }
3831
3832 pub(crate) fn commit_pending_changes(
3837 &self,
3838 cwd: &Path,
3839 baseline: &WorktreeBaseline,
3840 preferred_subject: &str,
3841 fallback_subject: &str,
3842 ) -> Result<bool> {
3843 let mut artifacts = IgnoredArtifacts::default();
3844 self.refuse_changed_attributes(cwd, baseline)?;
3845 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3846 refuse_unsafe_index_flags(cwd)?;
3847 if !self.has_uncommitted_changes(cwd)? {
3848 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3849 artifacts.report(cwd);
3850 return Ok(false);
3851 }
3852 self.stage_managed_changes(cwd, baseline).map_err(|e| {
3853 e.with_message(format!(
3854 "could not stage changes in {}: {}",
3855 cwd.display(),
3856 e.last_line()
3857 ))
3858 })?;
3859 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3862 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3863 if !changed_gitlinks.is_empty() {
3864 let listed = list_paths(&changed_gitlinks);
3865 bail!(
3866 "the editing call added or changed a gitlink at {listed}. It was staged but not \
3867 committed because the referenced repository objects might exist only inside \
3868 this worktree. The worktree was kept for recovery."
3869 );
3870 }
3871 let mut subject = self.clean_title(preferred_subject)?;
3872 if subject.trim().is_empty() {
3873 subject = self.clean_title(fallback_subject)?;
3874 }
3875 self.commit_staged_changes(cwd, &subject).map_err(|e| {
3876 e.with_message(format!(
3877 "could not commit changes in {}: {}. The staged files were kept.",
3878 cwd.display(),
3879 e.last_line()
3880 ))
3881 })?;
3882 if has_tracked_or_staged_work(cwd)? {
3883 bail!(
3884 "the commit in {} left additional uncommitted files. They were kept for \
3885 recovery.",
3886 cwd.display()
3887 );
3888 }
3889 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3890 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3891 artifacts.report(cwd);
3892 Ok(true)
3893 }
3894
3895 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3896 let after = ignored_untracked_state(cwd)?;
3897 self.git_at_without_automation(cwd, &["add", "-u"])?;
3898 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3899 if paths.is_empty() {
3900 return Ok(());
3901 }
3902 let mut input = Vec::new();
3903 for path in paths {
3904 input.extend(os_str_bytes(path.as_os_str())?);
3905 input.push(0);
3906 }
3907 let argv = git_without_automation_argv(&[
3908 "--literal-pathspecs",
3909 "add",
3910 "--pathspec-from-file=-",
3911 "--pathspec-file-nul",
3912 ]);
3913 proc::run_with_input_bytes(
3914 &argv,
3915 &self.git_opts(Some(cwd), true).stop_descendants(true),
3916 &input,
3917 )?;
3918 Ok(())
3919 }
3920
3921 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3924 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3925 .map(|_| ())
3926 }
3927
3928 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3935 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3936 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3937 .trim()
3938 .parse()
3939 .unwrap_or(0)
3940 }
3941
3942 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3946 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3947 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3948 .lines()
3949 .map(str::to_string)
3950 .collect()
3951 }
3952
3953 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3968 let ancestor = self
3969 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3970 .is_ok();
3971 if !ancestor {
3972 return None;
3973 }
3974 let range = format!("{earlier}..{later}");
3975 Some(
3976 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3977 .lines()
3978 .map(str::to_string)
3979 .collect(),
3980 )
3981 }
3982
3983 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3986 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3987 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3988 .lines()
3989 .map(str::trim)
3990 .filter(|line| !line.is_empty())
3991 .map(str::to_string)
3992 .collect()
3993 }
3994
3995 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
4011 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4012 self.git_try_at(
4013 Some(cwd),
4014 &["diff", "--name-only", "--no-renames", "-z", &range],
4015 )
4016 .split('\0')
4017 .filter(|path| !path.is_empty())
4018 .map(str::to_string)
4019 .collect()
4020 }
4021
4022 pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
4037 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4038 let argv = git_without_maintenance_argv(&[
4039 "-c",
4040 "core.quotePath=false",
4041 "diff",
4042 "--no-color",
4043 "--no-ext-diff",
4044 "--no-textconv",
4045 "--unified=3",
4046 "--inter-hunk-context=0",
4047 "--diff-algorithm=myers",
4048 "--indent-heuristic",
4049 "--submodule=short",
4050 "--find-renames",
4051 "--src-prefix=a/",
4052 "--dst-prefix=b/",
4053 &range,
4054 ]);
4055 let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
4056 new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
4057 }
4058
4059 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
4062 self.git_try_at(
4063 Some(cwd),
4064 &[
4065 "diff",
4066 "--name-only",
4067 "--no-renames",
4068 "-z",
4069 &format!("{from}..{to}"),
4070 ],
4071 )
4072 .split('\0')
4073 .filter(|path| !path.is_empty())
4074 .map(str::to_string)
4075 .collect()
4076 }
4077
4078 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
4081 let base_ref = self.base_ref(cwd, base);
4082 let out = self
4083 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
4084 .map_err(|e| {
4085 spar_err!(
4086 "could not find where {refname} and {base_ref} diverged. {}",
4087 e.last_line()
4088 )
4089 })?;
4090 let sha = out.trim().to_string();
4091 if sha.is_empty() {
4092 bail!("{refname} and {base_ref} share no history");
4093 }
4094 Ok(sha)
4095 }
4096
4097 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
4098 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4099 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
4100 full.trim().to_string()
4101 }
4102
4103 pub fn rewrite_commits_if_needed(
4116 &self,
4117 cwd: &Path,
4118 base: &str,
4119 floor: Option<&str>,
4120 ) -> Result<()> {
4121 let base_ref = self.base_ref(cwd, base);
4122 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
4123 self.report_commits_left_alone(cwd, &base_ref, floor);
4124
4125 let range = format!("{floor}..HEAD");
4126 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
4127
4128 let offenders = raw
4129 .split('\x1e')
4130 .filter_map(|entry| entry.split_once('\0'))
4131 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4132 .count();
4133 if offenders == 0 {
4134 return Ok(());
4135 }
4136 logdim!("{offenders} commit message(s) violated style rules, rewriting");
4137
4138 let exe = self_binary()?;
4139 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
4140
4141 let argv: Vec<String> = [
4142 "git",
4143 "filter-branch",
4144 "-f",
4145 "--msg-filter",
4146 &filter,
4147 &range,
4148 ]
4149 .iter()
4150 .map(|s| s.to_string())
4151 .collect();
4152 let opts = ExecOpts::new()
4153 .cwd(cwd)
4154 .check(false)
4155 .timeout_secs(600)
4156 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4157 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4158 .env(
4159 "SPAR_BAN_AI_ATTRIBUTION",
4160 bool_env(self.style.ban_ai_attribution),
4161 );
4162 let _ = proc::run(&argv, &opts);
4163
4164 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4165 if !style::violations(&after, &self.style).is_empty() {
4166 bail!(
4167 "commit messages still violate style rules after a rewrite in {}.",
4168 cwd.display()
4169 );
4170 }
4171 Ok(())
4172 }
4173
4174 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4179 if floor == base_ref {
4180 return;
4181 }
4182 let raw = self.git_try_at(
4183 Some(cwd),
4184 &[
4185 "log",
4186 &format!("{base_ref}..{floor}"),
4187 "--format=%H%x00%B%x1e",
4188 ],
4189 );
4190 let left: Vec<&str> = raw
4191 .split('\x1e')
4192 .filter_map(|entry| entry.split_once('\0'))
4193 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4194 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4195 .collect();
4196 if left.is_empty() {
4197 return;
4198 }
4199 logdim!(
4200 "{} commit message(s) already on the branch break the style rules and were left \
4201 alone: {}. spar does not rewrite commits it did not write.",
4202 left.len(),
4203 left.join(", ")
4204 );
4205 }
4206
4207 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4222 self.push_to(cwd, &PushTarget::Origin, branch, expected)
4223 }
4224
4225 pub fn push_to(
4227 &self,
4228 cwd: &Path,
4229 target: &PushTarget,
4230 branch: &str,
4231 expected: Option<&str>,
4232 ) -> Result<()> {
4233 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4234 if let Some(head) = expected {
4235 self.refuse_push_that_drops(cwd, head)?;
4236 }
4237 let pushed = self
4238 .git_push(cwd, target, branch, expected)
4239 .map_err(|e| push_error(target, branch, &e));
4240 self.record_write(pushed)
4241 }
4242
4243 pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4255 let origin = PushTarget::Origin;
4256 let refused = match self.git_push(cwd, &origin, branch, None) {
4257 Ok(()) => return self.record_write(Ok(origin)),
4258 Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4259 Err(e) => {
4260 let err = push_error(&origin, branch, &e);
4261 let err = if push_denial(&e.to_string()).is_some() {
4262 spar_err!(
4263 "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4264 instead."
4265 )
4266 } else {
4267 err
4268 };
4269 return self.record_write(Err(err));
4270 }
4271 };
4272 self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4273 }
4274
4275 fn publish_on_fork(
4276 &self,
4277 cwd: &Path,
4278 branch: &str,
4279 base: &str,
4280 refused: &crate::error::SparError,
4281 ) -> Result<PushTarget> {
4282 let fork = self.fork().map_err(|e| {
4283 spar_err!(
4284 "{} spar could not publish it from a fork instead: {}",
4285 push_error(&PushTarget::Origin, branch, refused),
4286 e.last_line()
4287 )
4288 })?;
4289 let lease = self.fork_branch_lease(&fork, branch, base)?;
4290 let refused = refused.to_string();
4291 log!(
4292 "{} Publishing {branch} from the fork {} instead.",
4293 push_denial(&refused).unwrap_or("origin refused the push."),
4294 fork.full_name
4295 );
4296 let target = PushTarget::Fork(fork);
4297 self.git_push(cwd, &target, branch, lease.as_deref())
4300 .map_err(|e| push_error(&target, branch, &e))?;
4301 Ok(target)
4302 }
4303
4304 fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4312 let live_ref = format!("refs/heads/{branch}");
4313 let unreadable = |e: crate::error::SparError| {
4314 spar_err!(
4315 "could not check whether the fork {} already has {branch}. {}",
4316 fork.full_name,
4317 e.last_line()
4318 )
4319 };
4320 let listed = self
4321 .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4322 .map_err(unreadable)?;
4323 let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4324 return Ok(None);
4325 };
4326 let local = format!("refs/spar/fork/{branch}");
4327 self.git(&[
4328 "fetch",
4329 "--no-tags",
4330 &fork.url,
4331 &format!("+{live_ref}:{local}"),
4332 ])
4333 .map_err(unreadable)?;
4334 let ahead = self.commit_count_checked(&self.root, &local, base);
4335 let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4336 self.git_try(&["update-ref", "-d", &local]);
4337 let ahead = ahead?;
4338 if !held {
4339 bail!(
4340 "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4341 and no pull request accounts for them. Publishing over it would lose that work.\n\
4342 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4343 run spar again.",
4344 fork.full_name,
4345 fork.url
4346 );
4347 }
4348 Ok(Some(tip))
4349 }
4350
4351 fn git_push(
4352 &self,
4353 cwd: &Path,
4354 target: &PushTarget,
4355 branch: &str,
4356 expected: Option<&str>,
4357 ) -> Result<()> {
4358 let refspec = format!("HEAD:refs/heads/{branch}");
4359 let lease = match (expected, target) {
4360 (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4361 (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4365 (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4366 };
4367 self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4368 .map(|_| ())
4369 }
4370
4371 fn fork(&self) -> Result<Fork> {
4378 match self.existing_fork() {
4379 Ok(Some(fork)) => return Ok(fork),
4380 Ok(None) => {}
4381 Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4382 }
4383 let answer = self
4384 .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4385 .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4386 let fork = fork_from_api(&answer, &self.origin_push_url())?;
4387 self.wait_for_fork(&fork)?;
4388 Ok(self.fork.get_or_init(|| fork).clone())
4389 }
4390
4391 fn existing_fork(&self) -> Result<Option<Fork>> {
4394 if let Some(fork) = self.fork.get() {
4395 return Ok(Some(fork.clone()));
4396 }
4397 let answer = self
4398 .gh(&[
4399 "api",
4400 "graphql",
4401 "-F",
4402 "owner={owner}",
4403 "-F",
4404 "name={repo}",
4405 "-f",
4406 "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4407 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4408 login } sshUrl url } } } }",
4409 "--jq",
4410 ".data.repository.forks.nodes",
4411 ])
4412 .map_err(|e| {
4413 spar_err!(
4414 "could not look for a fork of this repository. {}",
4415 e.last_line()
4416 )
4417 })?;
4418 let found = fork_from_listing(&answer, &self.origin_push_url())?;
4419 Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4420 }
4421
4422 fn origin_refuses_push(&self) -> bool {
4426 self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4427 .trim()
4428 == "false"
4429 }
4430
4431 fn origin_push_url(&self) -> String {
4432 self.git_try(&["remote", "get-url", "--push", "origin"])
4433 }
4434
4435 pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4440 if !pr.is_cross_repository {
4441 return Some(PushTarget::Origin);
4442 }
4443 let viewer = self.viewer_login().ok()?;
4444 own_fork_target(pr, viewer, &self.origin_push_url())
4445 }
4446
4447 fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4448 const TRIES: u32 = 30;
4449 let mut tries = 1;
4450 loop {
4451 match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4452 Ok(_) => return Ok(()),
4453 Err(e) if tries >= TRIES => bail!(
4454 "the fork {} could not be reached after {} seconds. {}",
4455 fork.full_name,
4456 TRIES * 2,
4457 e.last_line()
4458 ),
4459 Err(_) => {
4460 tries += 1;
4461 std::thread::sleep(std::time::Duration::from_secs(2));
4462 }
4463 }
4464 }
4465 }
4466
4467 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4475 let head = self.head_oid_checked(cwd)?;
4476 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4477 return Ok(());
4478 }
4479 bail!(
4480 "refusing to push {head} because it does not contain the reviewed head {expected}. \
4481 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4482 the intended commits on top of it before resuming.",
4483 cwd.display()
4484 )
4485 }
4486
4487 pub fn push_split_branch(
4496 &self,
4497 cwd: &Path,
4498 branch: &str,
4499 ) -> std::result::Result<(), SplitPushError> {
4500 let remote_ref = format!("refs/heads/{branch}");
4501 let lease = format!("--force-with-lease={remote_ref}:");
4502 let refspec = format!("HEAD:{remote_ref}");
4503 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4504 Ok(_) => Ok(()),
4505 Err(push_error) => {
4506 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4507 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4508 reconcile_failed_split_push(branch, push_error, local, remote)
4509 }
4510 };
4511 self.record_write(result)
4512 }
4513
4514 pub fn gh(&self, args: &[&str]) -> Result<String> {
4517 self.gh_at(None, args)
4518 }
4519
4520 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4521 let mut argv = vec!["gh".to_string()];
4522 argv.extend(args.iter().map(|s| s.to_string()));
4523 proc::run(
4524 &argv,
4525 &ExecOpts::new()
4526 .cwd(cwd.unwrap_or(&self.root))
4527 .timeout_secs(300),
4528 )
4529 }
4530
4531 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4537 let mut argv = vec!["gh".to_string()];
4538 argv.extend(args.iter().map(|s| s.to_string()));
4539 proc::run(
4540 &argv,
4541 &ExecOpts::new()
4542 .cwd(&self.root)
4543 .timeout_secs(300)
4544 .stdin(stdin),
4545 )
4546 }
4547
4548 pub fn gh_try(&self, args: &[&str]) -> String {
4549 let mut argv = vec!["gh".to_string()];
4550 argv.extend(args.iter().map(|s| s.to_string()));
4551 proc::run(
4552 &argv,
4553 &ExecOpts::new()
4554 .cwd(&self.root)
4555 .check(false)
4556 .timeout_secs(300),
4557 )
4558 .unwrap_or_default()
4559 }
4560
4561 pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4566 let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4567 for (name, value) in headers {
4568 argv.push("-H".to_string());
4569 argv.push(format!("{name}: {value}"));
4570 }
4571 argv.push(endpoint.to_string());
4572 let out = proc::exec(
4573 &argv,
4574 &ExecOpts::new()
4575 .cwd(&self.root)
4576 .check(false)
4577 .timeout_secs(300),
4578 )?;
4579 parse_api_response(&out.stdout)
4580 .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4581 }
4582
4583 pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4585 let text = self.gh(&[
4586 "pr",
4587 "list",
4588 "--state",
4589 "open",
4590 "--limit",
4591 "500",
4592 "--json",
4593 PR_SWEEP_FIELDS,
4594 ])?;
4595 serde_json::from_str(&text)
4596 .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4597 }
4598
4599 pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4601 let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4602 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4603 }
4604
4605 pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4610 let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4612 if slug.is_empty() || login.is_empty() {
4613 return None;
4614 }
4615 let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4616 let resp = self.gh_api_raw(&path, &[]).ok()?;
4617 match resp.status {
4618 200 => {
4619 let body: Value = serde_json::from_str(&resp.body).ok()?;
4620 Some(body.get("state")?.as_str()? == "active")
4621 }
4622 404 => Some(false),
4623 _ => None,
4624 }
4625 }
4626
4627 pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4634 let text = self
4635 .gh(&[
4636 "api",
4637 "graphql",
4638 "-F",
4639 "owner={owner}",
4640 "-F",
4641 "repo={repo}",
4642 "-F",
4643 &format!("number={number}"),
4644 "-f",
4645 &format!("query={REQUESTS_QUERY}"),
4646 ])
4647 .ok()?;
4648 parse_review_requests(&text)
4649 }
4650
4651 pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4653 if from.is_empty() || to.is_empty() {
4654 return None;
4655 }
4656 let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4657 self.gh_try(&["api", &path, "--jq", ".total_commits"])
4658 .trim()
4659 .parse()
4660 .ok()
4661 }
4662
4663 pub fn repo_name(&self) -> Option<String> {
4665 let name = self.gh_try(&[
4666 "repo",
4667 "view",
4668 "--json",
4669 "nameWithOwner",
4670 "--jq",
4671 ".nameWithOwner",
4672 ]);
4673 let name = name.trim();
4674 (!name.is_empty()).then(|| name.to_string())
4675 }
4676
4677 pub fn listen_state_path(&self) -> PathBuf {
4678 self.root.join(STATE_DIR).join("state").join("listen.json")
4679 }
4680
4681 pub fn listen_lock_path(&self) -> PathBuf {
4682 self.root.join(STATE_DIR).join("state").join("listen.lock")
4683 }
4684
4685 pub fn viewer_login(&self) -> Result<&str> {
4696 if let Some(login) = self.viewer.get() {
4697 return Ok(login);
4698 }
4699 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4700 let login = if !rest.trim().is_empty() {
4701 rest.trim().to_string()
4702 } else {
4703 self.gh(&[
4706 "api",
4707 "graphql",
4708 "-f",
4709 "query={ viewer { login } }",
4710 "--jq",
4711 ".data.viewer.login",
4712 ])
4713 .map_err(|e| {
4714 spar_err!(
4715 "could not find out who `gh` is authenticated as, so spar cannot tell its \
4716 own comments from anybody else's. {}\nRun `gh auth status`.",
4717 e.last_line()
4718 )
4719 })?
4720 .trim()
4721 .to_string()
4722 };
4723 if login.is_empty() {
4724 bail!("`gh` reported an empty login. Run `gh auth status`.");
4725 }
4726 Ok(self.viewer.get_or_init(|| login))
4727 }
4728
4729 pub fn read_issue(&self, number: i64) -> Result<Issue> {
4735 let text = self
4736 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4737 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4738 serde_json::from_str(&text)
4739 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4740 }
4741
4742 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4743 let mut issues = Vec::new();
4744 for number in numbers {
4745 let text = self
4746 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4747 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4748 let issue: Issue = serde_json::from_str(&text)
4749 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4750 if issue.is_closed() {
4751 crate::log!("issue #{number} is closed, skipping");
4752 continue;
4753 }
4754 issues.push(issue);
4755 }
4756 if issues.is_empty() {
4757 bail!("no open issues to work on");
4758 }
4759 Ok(issues)
4760 }
4761
4762 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4768 #[derive(Deserialize)]
4769 struct Row {
4770 number: i64,
4771 }
4772 let text = self.gh(&[
4773 kind,
4774 "list",
4775 "--state",
4776 "open",
4777 "--limit",
4778 &FETCH_CEILING.to_string(),
4779 "--json",
4780 "number",
4781 ])?;
4782 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4783 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4784 numbers.sort_unstable();
4785
4786 let noun = if kind == "issue" { "issues" } else { "PRs" };
4787 let found = numbers.len();
4788 if min_number > 0 {
4789 numbers.retain(|n| *n >= min_number);
4790 let skipped = found - numbers.len();
4791 if skipped > 0 {
4792 crate::log!("{skipped} open {noun} below #{min_number} skipped");
4793 }
4794 }
4795 if found >= FETCH_CEILING {
4796 crate::log!(
4797 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4798 considered."
4799 );
4800 }
4801 if numbers.len() > limit {
4802 crate::log!(
4803 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4804 explicitly.",
4805 numbers.len()
4806 );
4807 numbers.truncate(limit);
4808 }
4809 Ok(numbers)
4810 }
4811
4812 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4814 self.open_numbers("issue", limit, min_number)
4815 }
4816
4817 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4818 self.open_numbers("pr", limit, min_number)
4819 }
4820
4821 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4822 self.branch_prs(branch, "open").into_iter().next()
4823 }
4824
4825 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4828 let text = self.gh(&[
4829 "pr",
4830 "list",
4831 "--head",
4832 branch,
4833 "--base",
4834 base,
4835 "--state",
4836 "open",
4837 "--json",
4838 "number,url,title,baseRefName",
4839 ])?;
4840 pr_for_base(&text, branch, base)
4841 }
4842
4843 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4847 self.branch_prs(branch, "all")
4848 }
4849
4850 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4851 let text = self.gh_try(&[
4852 "pr",
4853 "list",
4854 "--head",
4855 branch,
4856 "--state",
4857 state,
4858 "--json",
4859 "number,url,title",
4860 ]);
4861 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4862 }
4863
4864 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4870 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4871 let text = self
4872 .gh(&[
4873 "api",
4874 &path,
4875 "--jq",
4876 "if .pull_request then \"pr\" else \"issue\" end",
4877 ])
4878 .map_err(|e| {
4879 spar_err!(
4880 "no issue or pull request #{number} in this repository. {}",
4881 e.last_line()
4882 )
4883 })?;
4884 match text.trim() {
4885 "pr" => Ok(ItemKind::Pr),
4886 "issue" => Ok(ItemKind::Issue),
4887 other => Err(spar_err!(
4888 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4889 )),
4890 }
4891 }
4892
4893 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4904 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4905 if let Some(pr) = mine {
4906 return Ok(Some(pr));
4907 }
4908 let listed = match self.gh(&[
4909 "pr",
4910 "list",
4911 "--state",
4912 "open",
4913 "--limit",
4914 &FETCH_CEILING.to_string(),
4915 "--json",
4916 "number,url,title,baseRefName,closingIssuesReferences",
4917 ]) {
4918 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4919 spar_err!("could not read the pull request list while looking for #{issue}")
4920 })?,
4921 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4922 };
4923 if listed.len() >= FETCH_CEILING {
4924 logwarn!(
4925 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
4926 );
4927 }
4928 let default = self.default_branch(base);
4929 if !base.eq_ignore_ascii_case(&default) {
4930 logwarn!(
4934 "#{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."
4935 );
4936 }
4937 Ok(pick_linked_pr(&listed, issue))
4938 }
4939
4940 fn pr_for_branch_failing_closed(
4942 &self,
4943 branch: &str,
4944 base: &str,
4945 issue: i64,
4946 ) -> Result<Option<PrRef>> {
4947 match self.try_pr_for_branch(branch, base) {
4948 Ok(found) => Ok(found),
4949 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4950 }
4951 }
4952
4953 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4962 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4963 return Err(spar_err!(
4964 "could not check whether a pull request already exists for #{issue}: {}. \
4965 Refusing to implement over the top of work that may already be open.",
4966 e.last_line()
4967 ));
4968 }
4969 logwarn!(
4970 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4971 whether a pull request already exists for it.",
4972 e.last_line()
4973 );
4974 Ok(())
4975 }
4976
4977 pub fn pr_view(&self, number: i64) -> Result<PrView> {
4978 let text = self.gh(&[
4979 "pr",
4980 "view",
4981 &number.to_string(),
4982 "--json",
4983 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
4984 isCrossRepository,headRepositoryOwner,headRepository",
4985 ])?;
4986 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4987 }
4988
4989 fn try_pr_state(&self, number: i64) -> Result<String> {
4990 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4991 serde_json::from_str::<Value>(text.trim())
4992 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4993 .get("state")
4994 .and_then(Value::as_str)
4995 .map(str::to_string)
4996 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4997 }
4998
4999 pub fn pr_state(&self, number: i64) -> String {
5000 self.try_pr_state(number).unwrap_or_default()
5001 }
5002
5003 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
5005 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
5006 let oid = serde_json::from_str::<Value>(&text)
5007 .ok()
5008 .and_then(|value| {
5009 value
5010 .get("headRefOid")
5011 .and_then(Value::as_str)
5012 .map(str::trim)
5013 .filter(|oid| !oid.is_empty())
5014 .map(str::to_string)
5015 })
5016 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
5017 Ok(oid)
5018 }
5019
5020 pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
5024 let pull = self.pr_head_oid(pr.number)?;
5025 let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
5026 None
5027 } else {
5028 let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
5029 let listed = self
5030 .git(&["ls-remote", "--heads", "origin", &remote_ref])
5031 .map_err(|e| {
5032 spar_err!(
5033 "could not read where {} is on origin: {}",
5034 pr.head_ref_name.trim(),
5035 e.last_line()
5036 )
5037 })?;
5038 remote_head_oid(&listed, &remote_ref)?
5039 };
5040 Ok(LiveHead { pull, branch })
5041 }
5042
5043 pub fn create_pr(
5048 &self,
5049 cwd: &Path,
5050 branch: &str,
5051 target: &PushTarget,
5052 base: &str,
5053 title: &str,
5054 body: &str,
5055 ) -> Result<PrRef> {
5056 let title = self.record_failed_write(self.clean_title(title))?;
5057 let body = self.record_failed_write(self.clean(body))?;
5058 let head = target.head(branch);
5059 let mut argv = vec![
5060 "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
5061 ];
5062 if self.drafts != Drafts::Never {
5063 argv.push("--draft");
5064 }
5065 let created = self.gh_at(Some(cwd), &argv);
5066 let found = self.try_pr_for_branch(branch, base);
5067 self.record_write(reconcile_pr_creation(branch, created, found))
5068 }
5069
5070 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
5071 let body = signed(&self.record_failed_write(self.clean(body))?);
5072 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5073 if has_exact_comment(&comments, &body) {
5074 return Ok(());
5075 }
5076 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
5077 let result = match posted {
5078 Ok(_) => Ok(()),
5079 Err(post_error) => {
5080 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5081 }
5082 };
5083 self.record_write(result)
5084 }
5085
5086 fn pr_is_own(&self, number: i64) -> Result<bool> {
5090 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
5091 let author = serde_json::from_str::<Value>(&text)
5092 .ok()
5093 .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
5094 .unwrap_or_default();
5095 let viewer = self.viewer_login()?.to_string();
5096 Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
5097 }
5098
5099 pub fn submit_review(
5120 &self,
5121 number: i64,
5122 verdict: Option<crate::model::ReviewVerdict>,
5123 body: &str,
5124 commit: &str,
5125 comments: &[InlineComment],
5126 ) -> Result<()> {
5127 let own = match verdict {
5128 Some(_) => self.record_failed_write(self.pr_is_own(number))?,
5129 None => false,
5130 };
5131 let text = match (verdict, own) {
5132 (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
5133 _ => body.to_string(),
5134 };
5135 let body = signed(&self.record_failed_write(self.clean(&text))?);
5136 let mut inline = Vec::with_capacity(comments.len());
5137 for c in comments {
5138 inline.push(InlineComment {
5139 body: signed(&self.record_failed_write(self.clean(&c.body))?),
5140 ..c.clone()
5141 });
5142 }
5143 let viewer = self.record_failed_write(self.viewer_login())?.to_string();
5144 let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
5145 if has_viewer_review(&reviews, &viewer, &body, commit) {
5146 return Ok(());
5147 }
5148 let event = match (verdict, own) {
5149 (Some(v), false) => v.api_event(),
5150 _ => "COMMENT",
5151 };
5152 let result = if inline.is_empty() {
5153 self.post_review(number, &body, &viewer, commit, event)
5154 } else {
5155 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5156 let payload = review_payload(commit, event, &body, &inline).to_string();
5157 match self.gh_stdin(
5158 &["api", "-X", "POST", &path, "--input", "-", "--silent"],
5159 &payload,
5160 ) {
5161 Ok(_) => Ok(()),
5162 Err(post_error) if !refused(&post_error) => reconcile_review_post(
5163 number,
5164 &body,
5165 &viewer,
5166 commit,
5167 post_error,
5168 self.try_pr_reviews(number),
5169 ),
5170 Err(post_error) => match self.try_pr_reviews(number) {
5171 Ok(reviews) if !has_viewer_review(&reviews, &viewer, &body, commit) => {
5172 logwarn!(
5173 "the review of #{number} with its line comments did not go up ({}), \
5174 so it is going up with every finding in its body only",
5175 post_error.last_line()
5176 );
5177 self.post_review(number, &body, &viewer, commit, event)
5178 }
5179 reviews => {
5180 reconcile_review_post(number, &body, &viewer, commit, post_error, reviews)
5181 }
5182 },
5183 }
5184 };
5185 self.record_write(result)
5186 }
5187
5188 fn post_review(
5190 &self,
5191 number: i64,
5192 body: &str,
5193 viewer: &str,
5194 commit: &str,
5195 event: &str,
5196 ) -> Result<()> {
5197 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5198 let posted = self.gh(&[
5199 "api",
5200 "-X",
5201 "POST",
5202 &path,
5203 "-f",
5204 &format!("commit_id={commit}"),
5205 "-f",
5206 &format!("event={event}"),
5207 "-f",
5208 &format!("body={body}"),
5209 "--silent",
5210 ]);
5211 match posted {
5212 Ok(_) => Ok(()),
5213 Err(post_error) => reconcile_review_post(
5214 number,
5215 body,
5216 viewer,
5217 commit,
5218 post_error,
5219 self.try_pr_reviews(number),
5220 ),
5221 }
5222 }
5223
5224 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5225 let body = signed(&self.record_failed_write(self.clean(body))?);
5226 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5227 if has_exact_comment(&comments, &body) {
5228 return Ok(());
5229 }
5230 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5231 let result = match posted {
5232 Ok(_) => Ok(()),
5233 Err(post_error) => {
5234 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5235 }
5236 };
5237 self.record_write(result)
5238 }
5239
5240 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5245 self.comment_issue(number, body)?;
5246 let n = number.to_string();
5247 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5248 Ok(_) => Ok(()),
5249 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5251 spar_err!(
5252 "commented on #{number} but could not close it: {}",
5253 e.last_line()
5254 )
5255 }),
5256 };
5257 self.record_write(closed)
5258 }
5259
5260 pub fn edit_issue_body(
5275 &self,
5276 number: i64,
5277 expected: &str,
5278 body: &str,
5279 inserted: &str,
5280 ) -> Result<()> {
5281 let cleaned = self.record_failed_write(self.clean(inserted))?;
5282 if cleaned.trim() != inserted.trim() {
5283 return self.record_failed_write(Err(spar_err!(
5284 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5285 )));
5286 }
5287 let current = self.record_failed_write(self.issue_body(number))?;
5288 if current != expected {
5289 return self.record_failed_write(Err(spar_err!(
5290 "the body of #{number} changed since it was read, so it was left alone rather \
5291 than written over."
5292 )));
5293 }
5294 let edited = self.gh_stdin(
5295 &["issue", "edit", &number.to_string(), "--body-file", "-"],
5296 body,
5297 );
5298 let result = match edited {
5299 Ok(_) => Ok(()),
5300 Err(edit_error) => {
5301 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5302 }
5303 };
5304 self.record_write(result)
5305 }
5306
5307 pub fn issue_body(&self, number: i64) -> Result<String> {
5309 #[derive(Deserialize)]
5310 struct Row {
5311 #[serde(default)]
5312 body: Option<String>,
5313 }
5314 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5315 let row: Row = serde_json::from_str(text.trim())
5316 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5317 Ok(row.body.unwrap_or_default())
5318 }
5319
5320 pub fn open_issue_rows(&self) -> Vec<Issue> {
5325 let text = self.gh_try(&[
5326 "issue",
5327 "list",
5328 "--state",
5329 "open",
5330 "--limit",
5331 &FETCH_CEILING.to_string(),
5332 "--json",
5333 "number,title,body,labels,state,stateReason,url",
5334 ]);
5335 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5336 }
5337
5338 pub fn open_pr_rows(&self) -> Vec<PrRow> {
5340 let text = self.gh_try(&[
5341 "pr",
5342 "list",
5343 "--state",
5344 "open",
5345 "--limit",
5346 &FETCH_CEILING.to_string(),
5347 "--json",
5348 "number,title,changedFiles,additions,deletions",
5349 ]);
5350 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5351 }
5352
5353 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5354 self.create_issue_apart_from(title, body, None)
5355 }
5356
5357 pub fn create_issue_apart_from(
5358 &self,
5359 title: &str,
5360 body: &str,
5361 apart_from: Option<i64>,
5362 ) -> Result<String> {
5363 let title = self.record_failed_write(self.clean_title(title))?;
5364 let body = self.record_failed_write(self.clean_issue_body(body))?;
5365 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5366 let result = match created {
5367 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5368 created => {
5369 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5370 reconcile_issue_creation(&title, created, found)
5371 }
5372 };
5373 self.record_write(result)
5374 }
5375}
5376
5377#[derive(Debug, Clone)]
5379pub struct ExistingIssue {
5380 pub number: i64,
5381 pub url: String,
5382 pub title: String,
5383 pub body: String,
5384 pub open: bool,
5385}
5386
5387impl Repo {
5388 pub(crate) fn try_exact_issue_apart_from(
5389 &self,
5390 title: &str,
5391 body: &str,
5392 apart_from: Option<i64>,
5393 ) -> Result<Option<ExistingIssue>> {
5394 #[derive(Deserialize)]
5395 #[serde(rename_all = "camelCase")]
5396 struct Row {
5397 number: i64,
5398 #[serde(default)]
5399 title: String,
5400 #[serde(default)]
5401 url: String,
5402 #[serde(default)]
5403 body: Option<String>,
5404 #[serde(default)]
5405 state: String,
5406 }
5407
5408 let text = self.gh(&[
5409 "issue",
5410 "list",
5411 "--state",
5412 "all",
5413 "--limit",
5414 "100",
5415 "--json",
5416 "number,title,url,body,state,stateReason",
5417 ])?;
5418 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5419 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5420 Ok(rows
5421 .into_iter()
5422 .filter(|row| Some(row.number) != apart_from)
5423 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5424 .map(|row| ExistingIssue {
5425 number: row.number,
5426 url: row.url,
5427 title: row.title,
5428 body: row.body.unwrap_or_default(),
5429 open: row.state.eq_ignore_ascii_case("open"),
5430 }))
5431 }
5432
5433 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5441 self.find_similar_issue_apart_from(title, body, None)
5442 }
5443
5444 pub fn find_similar_issue_apart_from(
5452 &self,
5453 title: &str,
5454 body: &str,
5455 apart_from: Option<i64>,
5456 ) -> Option<ExistingIssue> {
5457 self.try_similar_issue(title, body, apart_from, true)
5458 .ok()
5459 .flatten()
5460 }
5461
5462 pub fn try_find_similar_issue_apart_from(
5464 &self,
5465 title: &str,
5466 body: &str,
5467 apart_from: Option<i64>,
5468 ) -> Result<Option<ExistingIssue>> {
5469 self.try_similar_issue(title, body, apart_from, false)
5470 }
5471
5472 fn try_similar_issue(
5474 &self,
5475 title: &str,
5476 body: &str,
5477 apart_from: Option<i64>,
5478 include_closed: bool,
5479 ) -> Result<Option<ExistingIssue>> {
5480 #[derive(Deserialize)]
5481 #[serde(rename_all = "camelCase")]
5482 struct Row {
5483 number: i64,
5484 #[serde(default)]
5485 title: String,
5486 #[serde(default)]
5487 url: String,
5488 #[serde(default)]
5489 body: String,
5490 #[serde(default)]
5491 state: String,
5492 }
5493 if title.trim().is_empty() {
5494 return Ok(None);
5495 }
5496 let query: String = title
5499 .chars()
5500 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5501 .take(120)
5502 .collect();
5503 let state = if include_closed { "all" } else { "open" };
5512 let text = self.gh(&[
5513 "issue",
5514 "list",
5515 "--state",
5516 state,
5517 "--limit",
5518 "100",
5519 "--search",
5520 query.trim(),
5521 "--json",
5522 "number,title,url,body,state,stateReason",
5523 ])?;
5524 let rows: Vec<Row> = serde_json::from_str(text.trim())
5525 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5526
5527 Ok(rows
5528 .into_iter()
5529 .filter(|row| Some(row.number) != apart_from)
5530 .find(|row| {
5531 row.title.trim().eq_ignore_ascii_case(title.trim())
5532 || textsim::covers(title, body, &row.title, &row.body)
5533 })
5534 .map(|row| ExistingIssue {
5535 number: row.number,
5536 url: row.url,
5537 title: row.title,
5538 open: row.state.eq_ignore_ascii_case("open"),
5539 body: row.body,
5540 }))
5541 }
5542
5543 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5545 #[derive(Deserialize)]
5546 struct Row {
5547 title: String,
5548 url: String,
5549 }
5550 let needle = title.trim().to_lowercase();
5551 if needle.is_empty() {
5552 return None;
5553 }
5554 let query: String = title
5556 .chars()
5557 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5558 .take(120)
5559 .collect();
5560 let text = self.gh_try(&[
5561 "issue",
5562 "list",
5563 "--state",
5564 "all",
5565 "--limit",
5566 "100",
5567 "--search",
5568 query.trim(),
5569 "--json",
5570 "number,title,url",
5571 ]);
5572 serde_json::from_str::<Vec<Row>>(text.trim())
5573 .ok()?
5574 .into_iter()
5575 .find(|row| row.title.trim().to_lowercase() == needle)
5576 .map(|row| row.url)
5577 }
5578
5579 pub fn mark_ready(&self, number: i64) -> bool {
5587 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5588 Ok(_) => true,
5589 Err(e) => {
5590 logdim!(
5591 "PR #{number} is approved but could not be taken out of draft: {}",
5592 e.last_line()
5593 );
5594 false
5595 }
5596 }
5597 }
5598
5599 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5601 let listed = self
5602 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5603 .ok()?;
5604 let wanted = format!("branch refs/heads/{branch}");
5605 let mut current: Option<PathBuf> = None;
5606 for line in listed.lines() {
5607 if let Some(path) = line.strip_prefix("worktree ") {
5608 current = Some(PathBuf::from(path));
5609 } else if line == wanted {
5610 return current;
5611 }
5612 }
5613 None
5614 }
5615
5616 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5632 let branch = self.pr_view(number).ok()?.head_ref_name;
5633 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5634 return None;
5635 }
5636 let mut released = None;
5637 if let Some(path) = self.worktree_holding(&branch) {
5638 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5639 return None;
5640 }
5641 if !matches!(
5642 self.has_recoverable_work(&path, Disposable::BuildOutput),
5643 Ok(false)
5644 ) {
5645 return None;
5646 }
5647 match self.remove_worktree_at(&path) {
5648 Ok(true) => released = Some(path),
5649 _ => return None,
5650 }
5651 }
5652 match self.delete_branch_if_safe(&branch) {
5653 Ok(true) => {
5654 self.forget_branch(&branch);
5655 Some(match released {
5656 Some(path) => format!(
5657 " The worktree at {} was released and branch {branch} deleted.",
5658 path.display()
5659 ),
5660 None => format!(" Branch {branch} was deleted."),
5661 })
5662 }
5663 _ => released.map(|path| {
5664 format!(
5665 " The worktree at {} was released, but branch {branch} was kept.",
5666 path.display()
5667 )
5668 }),
5669 }
5670 }
5671
5672 pub fn merge_pr(&self, number: i64) -> Result<()> {
5676 let n = number.to_string();
5677 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5678 Ok(_) => Ok(()),
5679 Err(e) => {
5680 if self.pr_state(number) == "MERGED" {
5681 let finished = self
5682 .finish_merged_branch_cleanup(number)
5683 .unwrap_or_default();
5684 logdim!(
5685 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5686 e.last_line()
5687 );
5688 Ok(())
5689 } else {
5690 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5691 }
5692 }
5693 };
5694 self.record_write(merged)
5695 }
5696
5697 pub fn merge_pr_at_head(
5699 &self,
5700 number: i64,
5701 expected_head: &str,
5702 delete_branch: bool,
5703 ) -> Result<()> {
5704 let n = number.to_string();
5705 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5706 Ok(_) => Ok(()),
5707 Err(e) => {
5708 if self.pr_state(number) == "MERGED" {
5709 let finished = self
5710 .finish_merged_branch_cleanup(number)
5711 .unwrap_or_default();
5712 logdim!(
5713 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5714 e.last_line()
5715 );
5716 Ok(())
5717 } else {
5718 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5719 }
5720 }
5721 };
5722 self.record_write(merged)
5723 }
5724
5725 pub fn followups_path(&self) -> PathBuf {
5730 self.root.join(STATE_DIR).join("followups.md")
5731 }
5732
5733 pub fn brainstorms_dir(&self) -> PathBuf {
5738 self.root.join(STATE_DIR).join("brainstorms")
5739 }
5740
5741 pub fn worked_followups_path(&self) -> PathBuf {
5749 self.root.join(STATE_DIR).join("followups.done.md")
5750 }
5751
5752 pub fn spend_path(&self) -> PathBuf {
5755 self.root.join(STATE_DIR).join("state").join("spend.json")
5756 }
5757
5758 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5764 if calls.is_empty() {
5765 return Ok(());
5766 }
5767 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5768 .ok()
5769 .and_then(|text| serde_json::from_str(&text).ok())
5770 .unwrap_or_default();
5771 all.extend(calls.iter().cloned());
5772 if let Some(parent) = self.spend_path().parent() {
5773 let _ = std::fs::create_dir_all(parent);
5774 }
5775 write_json_atomic(&self.spend_path(), &all)
5776 }
5777
5778 pub fn implementors_path(&self) -> PathBuf {
5784 self.root
5785 .join(STATE_DIR)
5786 .join("state")
5787 .join("implementors.json")
5788 }
5789
5790 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5791 std::fs::read_to_string(self.implementors_path())
5792 .ok()
5793 .and_then(|text| serde_json::from_str(&text).ok())
5794 .unwrap_or_default()
5795 }
5796
5797 pub fn remember_implementor(&self, issue: i64, agent: &str) {
5800 let mut known = self.read_implementors();
5801 known.insert(issue, agent.to_string());
5802 if let Some(parent) = self.implementors_path().parent() {
5803 let _ = std::fs::create_dir_all(parent);
5804 }
5805 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5806 logdim!("could not record who implemented #{issue}: {e}");
5807 }
5808 }
5809
5810 pub fn contested_path(&self) -> PathBuf {
5815 self.root
5816 .join(STATE_DIR)
5817 .join("state")
5818 .join("contested.json")
5819 }
5820
5821 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5823 std::fs::read_to_string(self.contested_path())
5824 .ok()
5825 .and_then(|text| serde_json::from_str(&text).ok())
5826 .unwrap_or_default()
5827 }
5828
5829 pub fn remember_contested(&self, items: &[ContestedItem]) {
5834 if items.is_empty() {
5835 return;
5836 }
5837 let mut known = self.read_contested();
5838 for item in items {
5839 known.insert(
5840 item.issue,
5841 Contested {
5842 title: item.title.clone(),
5843 positions: item.positions.clone(),
5844 reasons: item.reasons.clone(),
5845 },
5846 );
5847 }
5848 if let Some(parent) = self.contested_path().parent() {
5849 let _ = std::fs::create_dir_all(parent);
5850 }
5851 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5852 logdim!("could not record the contested issues: {e}");
5853 }
5854 }
5855
5856 pub fn forget_contested(&self, numbers: &[i64]) {
5858 let mut known = self.read_contested();
5859 let before = known.len();
5860 known.retain(|number, _| !numbers.contains(number));
5861 if known.len() == before {
5862 return;
5863 }
5864 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5865 logdim!("could not update the contested issues: {e}");
5866 }
5867 }
5868
5869 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5870 self.root
5871 .join(STATE_DIR)
5872 .join("state")
5873 .join(format!("checkin-{number}.json"))
5874 }
5875
5876 pub fn answer_state_path(&self, number: i64) -> PathBuf {
5878 self.root
5879 .join(STATE_DIR)
5880 .join("state")
5881 .join(format!("answer-{number}.json"))
5882 }
5883
5884 pub fn answer_notes_path(&self, number: i64) -> PathBuf {
5887 self.root
5888 .join(STATE_DIR)
5889 .join("answers")
5890 .join(format!("issue-{number}.md"))
5891 }
5892
5893 pub fn review_record_path(&self, number: i64) -> PathBuf {
5895 self.root
5896 .join(STATE_DIR)
5897 .join("state")
5898 .join(format!("review-{number}.json"))
5899 }
5900
5901 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5904 let path = self.review_record_path(number);
5905 let text = std::fs::read_to_string(&path).ok()?;
5906 match serde_json::from_str(&text) {
5907 Ok(record) => Some(record),
5908 Err(e) => {
5909 logdim!("could not read {}: {e}", path.display());
5910 None
5911 }
5912 }
5913 }
5914
5915 pub fn write_review_record(
5916 &self,
5917 number: i64,
5918 record: &crate::model::ReviewRecord,
5919 ) -> Result<()> {
5920 write_json_atomic(&self.review_record_path(number), record)
5921 }
5922
5923 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5938 let path = self.followups_path();
5939 let heading = format!("## {}", title.trim());
5940 for seen in [&path, &self.worked_followups_path()] {
5941 if let Ok(existing) = std::fs::read_to_string(seen) {
5942 if has_heading(&existing, title) {
5943 logdim!("follow-up already noted: {title}");
5944 return Followup::Covered(format!("note: {}", title.trim()));
5945 }
5946 }
5947 }
5948 if let Some(parent) = path.parent() {
5949 let _ = std::fs::create_dir_all(parent);
5950 }
5951 use std::io::Write;
5952 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5959 match std::fs::OpenOptions::new()
5960 .create(true)
5961 .append(true)
5962 .open(&path)
5963 {
5964 Ok(mut file) => match file.write_all(entry.as_bytes()) {
5965 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5966 Err(e) => {
5967 logdim!("could not write {}: {e}", path.display());
5968 Followup::Failed
5969 }
5970 },
5971 Err(e) => {
5972 logdim!("could not write {}: {e}", path.display());
5973 Followup::Failed
5974 }
5975 }
5976 }
5977
5978 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5983 let path = self.worked_followups_path();
5984 if let Some(parent) = path.parent() {
5985 let _ = std::fs::create_dir_all(parent);
5986 }
5987 use std::io::Write;
5988 let entry = format!(
5989 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5990 title.trim(),
5991 body.trim()
5992 );
5993 if let Ok(mut file) = std::fs::OpenOptions::new()
5994 .create(true)
5995 .append(true)
5996 .open(&path)
5997 {
5998 let _ = file.write_all(entry.as_bytes());
5999 }
6000 }
6001
6002 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
6011 self.root
6012 .join(STATE_DIR)
6013 .join("reviews")
6014 .join(format!("pr-{number}.md"))
6015 }
6016
6017 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
6023 let path = self.pending_comment_path(number);
6024 if let Some(parent) = path.parent() {
6025 std::fs::create_dir_all(parent)
6026 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6027 }
6028 std::fs::write(&path, text)
6029 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6030 Ok(path)
6031 }
6032
6033 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
6034 std::fs::read_to_string(self.pending_comment_path(number)).ok()
6035 }
6036
6037 pub fn state_path(&self, number: i64) -> PathBuf {
6038 self.root
6039 .join(STATE_DIR)
6040 .join("state")
6041 .join(format!("pr-{number}.json"))
6042 }
6043
6044 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
6045 let path = self.state_path(number);
6046 let text = std::fs::read_to_string(&path).ok()?;
6047 match serde_json::from_str(&text) {
6048 Ok(state) => Some(state),
6049 Err(_) => {
6050 logdim!("could not read {}, starting fresh", path.display());
6051 None
6052 }
6053 }
6054 }
6055
6056 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
6057 if let Some(local) = self.read_local_state(pr.number) {
6058 return Some(local);
6059 }
6060 if self.state_store.writes_pr() {
6061 return self.read_pr_state(pr.number);
6062 }
6063 None
6064 }
6065
6066 pub(crate) fn read_state_for_head(
6067 &self,
6068 pr: &PrView,
6069 actual_head: &str,
6070 ) -> Option<PersistedState> {
6071 let local = self
6072 .state_store
6073 .writes_local()
6074 .then(|| self.read_local_state(pr.number))
6075 .flatten();
6076 let remote = self
6077 .state_store
6078 .writes_pr()
6079 .then(|| self.read_pr_state(pr.number))
6080 .flatten();
6081 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
6082 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
6083 self.remember_checkpoint(pr.number, checkpoint);
6084 }
6085 choose_state_for_head(candidates, actual_head)
6086 }
6087
6088 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
6089 self.try_read_pr_state(number).ok().flatten()
6090 }
6091
6092 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
6093 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
6094 if let Some(state) = parse_state_comment(&body) {
6095 return Ok(Some(state));
6096 }
6097 }
6098 Ok(None)
6099 }
6100
6101 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6102 let remote_state = if self.state_store.writes_pr() {
6103 self.try_read_pr_state(number)
6104 } else {
6105 Ok(None)
6106 };
6107 self.write_state_after_remote_read(number, state, remote_state)
6108 }
6109
6110 fn write_state_after_remote_read(
6111 &self,
6112 number: i64,
6113 state: &PersistedState,
6114 remote_state: Result<Option<PersistedState>>,
6115 ) -> Result<()> {
6116 let remote_checkpoint = if self.state_store.writes_pr() {
6117 self.record_failed_write(remote_state)?
6118 .map(|saved| saved.checkpoint)
6119 .unwrap_or_default()
6120 } else {
6121 0
6122 };
6123 let local_checkpoint = self
6124 .state_store
6125 .writes_local()
6126 .then(|| self.read_local_state(number))
6127 .flatten()
6128 .map(|saved| saved.checkpoint)
6129 .unwrap_or_default();
6130 let mut stamped = state.clone();
6131 stamped.checkpoint = state
6132 .checkpoint
6133 .max(local_checkpoint)
6134 .max(remote_checkpoint)
6135 .max(self.remembered_checkpoint(number))
6136 .saturating_add(1);
6137 self.remember_checkpoint(number, stamped.checkpoint);
6138 if self.state_store.writes_local() {
6139 write_json_atomic(&self.state_path(number), &stamped)?;
6140 }
6141 if self.state_store.writes_pr() {
6142 self.write_pr_state(number, &stamped)?;
6143 }
6144 Ok(())
6145 }
6146
6147 fn remembered_checkpoint(&self, number: i64) -> u64 {
6148 self.checkpoints
6149 .lock()
6150 .unwrap_or_else(std::sync::PoisonError::into_inner)
6151 .get(&number)
6152 .copied()
6153 .unwrap_or_default()
6154 }
6155
6156 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6157 let mut checkpoints = self
6158 .checkpoints
6159 .lock()
6160 .unwrap_or_else(std::sync::PoisonError::into_inner);
6161 let saved = checkpoints.entry(number).or_default();
6162 *saved = (*saved).max(checkpoint);
6163 }
6164
6165 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6166 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6170 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6171 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6172 if let Some(id) = comment_id {
6173 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6174 let field = format!("body={body}");
6175 let written = self
6176 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6177 .map(|_| ());
6178 return self.record_write(written);
6179 }
6180 let written = self
6181 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6182 .map(|_| ());
6183 self.record_write(written)
6184 }
6185
6186 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6194 self.try_issue_comments(number).unwrap_or_default()
6195 }
6196
6197 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6198 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6199 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6200 }
6201
6202 pub fn issue_row(&self, number: i64) -> Result<Value> {
6206 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6207 let text = self
6208 .gh(&["api", &path])
6209 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6210 serde_json::from_str(&text)
6211 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6212 }
6213
6214 pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6217 #[derive(Deserialize)]
6218 struct Row {
6219 number: i64,
6220 }
6221 let name = self
6223 .repo_name()
6224 .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6225 let text = self.gh(&[
6226 "search",
6227 "issues",
6228 "--repo",
6229 &name,
6230 "--mentions",
6231 login,
6232 "--state",
6233 "open",
6234 "--limit",
6235 &FETCH_CEILING.to_string(),
6236 "--json",
6237 "number",
6238 ])?;
6239 let rows: Vec<Row> = serde_json::from_str(text.trim())
6240 .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6241 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6242 numbers.sort_unstable();
6243 numbers.truncate(limit);
6244 Ok(numbers)
6245 }
6246
6247 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6254 let viewer = self.viewer_login()?.to_string();
6255 let mut mine = Vec::new();
6256 let mut theirs = Vec::new();
6257 for comment in self.try_issue_comments(number)? {
6258 let Some(body) = comment.get("body").and_then(Value::as_str) else {
6259 continue;
6260 };
6261 if !body.contains("spar:state") {
6262 continue;
6263 }
6264 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6265 continue;
6266 };
6267 let author = comment
6268 .get("user")
6269 .and_then(|u| u.get("login"))
6270 .and_then(Value::as_str)
6271 .unwrap_or_default();
6272 if is_state_comment(body, author, &viewer) {
6273 mine.push((id, body.to_string()));
6274 } else {
6275 theirs.push(format!("@{author}"));
6276 }
6277 }
6278 if !theirs.is_empty() {
6279 logdim!(
6280 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6281 own state block is patched or deleted.",
6282 theirs.len(),
6283 theirs.join(", ")
6284 );
6285 }
6286 Ok(mine)
6287 }
6288
6289 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6290 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6291 }
6292
6293 pub fn clear_state(&self, number: i64) {
6295 let path = self.state_path(number);
6296 let _ = std::fs::remove_file(&path);
6297 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6298 }
6299
6300 pub fn prune_state(&self) -> Vec<String> {
6307 let base = self.root.join(STATE_DIR).join("state");
6308 let Ok(entries) = std::fs::read_dir(&base) else {
6309 return Vec::new();
6310 };
6311 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6312 for name in entries
6313 .flatten()
6314 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6315 {
6316 if let Some(number) = state_file_number(&name) {
6317 by_number.entry(number).or_default().push(name);
6318 }
6319 }
6320
6321 let mut removed = Vec::new();
6322 for (number, mut names) in by_number {
6323 if !is_finished(&self.pr_state(number)) {
6324 continue;
6325 }
6326 names.sort();
6327 for name in names {
6328 let _ = std::fs::remove_file(base.join(&name));
6329 removed.push(format!("state {name}"));
6330 }
6331 }
6332 removed
6333 }
6334
6335 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6339 #[derive(Deserialize)]
6340 struct Row {
6341 number: i64,
6342 }
6343 let numbers = match numbers {
6344 Some(numbers) => numbers,
6345 None => {
6346 let listed: Result<Vec<i64>> = (|| {
6347 let text = self.gh(&[
6348 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6349 ])?;
6350 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6351 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6352 Ok(rows.into_iter().map(|row| row.number).collect())
6353 })();
6354 match self.record_failed_write(listed) {
6355 Ok(numbers) => numbers,
6356 Err(e) => {
6357 logdim!("could not inspect pull requests for state cleanup: {e}");
6358 return Vec::new();
6359 }
6360 }
6361 }
6362 };
6363
6364 let mut removed = Vec::new();
6365 for number in numbers {
6366 let state = match self.record_failed_write(self.try_pr_state(number)) {
6367 Ok(state) => state,
6368 Err(e) => {
6369 logdim!("could not inspect PR #{number} for state cleanup: {e}");
6370 continue;
6371 }
6372 };
6373 if !is_finished(&state) {
6374 continue;
6375 }
6376 let comments = match self.record_failed_write(self.try_state_comments(number)) {
6377 Ok(comments) => comments,
6378 Err(e) => {
6379 logdim!("could not inspect state comments on PR #{number}: {e}");
6380 continue;
6381 }
6382 };
6383 for (id, _) in comments {
6384 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6385 let deleted = self
6386 .gh(&["api", "-X", "DELETE", &path, "--silent"])
6387 .map(|_| ());
6388 match self.record_write(deleted) {
6389 Ok(()) => removed.push(format!("state comment on PR #{number}")),
6390 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6391 }
6392 }
6393 }
6394 removed
6395 }
6396
6397 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6404 let base = self.root.join(WORKTREE_DIR);
6405 let mut removed = Vec::new();
6406 let known = self.known_branches();
6407
6408 if let Ok(entries) = std::fs::read_dir(&base) {
6409 let mut names: Vec<String> = entries
6410 .flatten()
6411 .filter(|e| e.path().is_dir())
6412 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6413 .collect();
6414 names.sort();
6415
6416 for name in names {
6417 if let Some(owner) = read_checkout_owner(&name) {
6421 let path = base.join(&name);
6422 if force_all {
6423 self.remove_worktree_at_force(&path);
6424 } else if process_is_running(owner) {
6425 continue;
6426 } else if has_recovery_marker(&path) {
6427 logdim!("kept {} for recovery", path.display());
6428 continue;
6429 } else {
6430 self.discard_read_checkout(&path);
6431 }
6432 if !path.exists() {
6433 removed.push(name);
6434 }
6435 continue;
6436 }
6437 if let Some(rest) = name.strip_prefix("review-") {
6440 let number: i64 = rest.parse().unwrap_or(-1);
6441 if !(force_all || is_finished(&self.pr_state(number))) {
6442 continue;
6443 }
6444 let path = base.join(&name);
6445 if force_all {
6446 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6447 if !belongs {
6448 return Ok(false);
6449 }
6450 let local_ref = review_ref(number);
6451 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6452 return Ok(false);
6453 }
6454 let head = self.head_oid_checked(&path)?;
6455 let recorded = self
6456 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6457 .trim()
6458 .to_string();
6459 Ok(head == recorded)
6460 });
6461 match owned {
6462 Ok(true) => {}
6463 Ok(false) => {
6464 logdim!(
6465 "kept {} because no matching SPAR review reference proves \
6466 ownership",
6467 path.display()
6468 );
6469 continue;
6470 }
6471 Err(e) => {
6472 logdim!(
6473 "kept {} because review ownership could not be verified: {}",
6474 path.display(),
6475 e.last_line()
6476 );
6477 continue;
6478 }
6479 }
6480 } else {
6481 if let Err(e) = self.refuse_review_worktree_changes(number) {
6482 logdim!(
6483 "kept {} because its review state could not be verified as \
6484 disposable: {}",
6485 path.display(),
6486 e.last_line()
6487 );
6488 continue;
6489 }
6490 }
6491 if force_all {
6492 if self.remove_worktree_at_force(&path) {
6493 self.git_try(&["update-ref", "-d", &review_ref(number)]);
6494 }
6495 } else {
6496 self.release_review_worktree(number);
6497 }
6498 if !path.exists() {
6499 removed.push(name);
6500 }
6501 continue;
6502 }
6503 let branch = format!("{}{name}", self.branch_prefix);
6504 if !(force_all || self.worktree_is_done(&branch)) {
6505 continue;
6506 }
6507 if !known.contains_key(&branch) {
6508 logdim!("kept {branch} because it has no branch record");
6509 continue;
6510 }
6511 let path = base.join(&name);
6512 if !force_all {
6513 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6514 Ok(true) => {
6515 logdim!(
6516 "kept {} because it contains uncommitted changes or ignored files",
6517 path.display()
6518 );
6519 continue;
6520 }
6521 Err(e) => {
6522 logdim!(
6523 "kept {} because its Git state could not be checked: {}",
6524 path.display(),
6525 e.last_line()
6526 );
6527 continue;
6528 }
6529 Ok(false) => {}
6530 }
6531 match self.branch_deletion_is_safe(&branch) {
6532 Ok(true) => {}
6533 Ok(false) => {
6534 logdim!(
6535 "kept {branch} because no surviving ref preserves its tip or \
6536 reflog-only commits"
6537 );
6538 continue;
6539 }
6540 Err(e) => {
6541 logdim!(
6542 "kept {branch} because preservation could not be verified: {}",
6543 e.last_line()
6544 );
6545 continue;
6546 }
6547 }
6548 }
6549 let removed_worktree = if force_all {
6550 self.remove_worktree_at_force(&path)
6551 } else {
6552 match self.remove_worktree_at(&path) {
6553 Ok(removed) => removed,
6554 Err(error) => {
6555 logdim!(
6556 "kept {branch} and {} because removal did not reach a confirmed \
6557 quiet point: {}",
6558 path.display(),
6559 error.last_line()
6560 );
6561 false
6562 }
6563 }
6564 };
6565 if !removed_worktree {
6566 continue;
6567 }
6568 if force_all {
6569 self.git_try(&["branch", "-D", &branch]);
6570 self.forget_branch(&branch);
6571 } else {
6572 match self.delete_branch_if_safe(&branch) {
6573 Ok(true) => self.forget_branch(&branch),
6574 Ok(false) => logdim!(
6575 "kept {branch} because its tip or reflog changed before deletion"
6576 ),
6577 Err(error) => logdim!(
6578 "kept {branch} because deletion safety could not be rechecked: {}",
6579 error.last_line()
6580 ),
6581 }
6582 }
6583 removed.push(name);
6584 }
6585 }
6586 removed.extend(self.prune_branches(force_all));
6587 removed
6588 }
6589
6590 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6597 let known = self.known_branches();
6598 let branches: Vec<String> = known.keys().cloned().collect();
6599 if branches.is_empty() {
6600 return Vec::new();
6601 }
6602
6603 let checked_out: Vec<String> = self
6604 .git_try(&["worktree", "list", "--porcelain"])
6605 .lines()
6606 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6607 .collect();
6608
6609 let existing: Vec<String> = self
6612 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6613 .lines()
6614 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6615 .collect();
6616
6617 let mut removed = Vec::new();
6618 for branch in branches {
6619 if !existing.contains(&branch) {
6620 self.forget_branch(&branch); continue;
6622 }
6623 if checked_out.contains(&branch) {
6624 continue;
6625 }
6626 if !(force_all || self.worktree_is_done(&branch)) {
6627 continue;
6628 }
6629 if !force_all {
6630 let Some(_record) = known.get(&branch) else {
6631 continue;
6632 };
6633 match self.branch_deletion_is_safe(&branch) {
6634 Ok(true) => {}
6635 Ok(false) => {
6636 logdim!(
6637 "kept {branch} because no surviving ref preserves its tip or \
6638 reflog-only commits"
6639 );
6640 continue;
6641 }
6642 Err(e) => {
6643 logdim!(
6644 "kept {branch} because preservation could not be verified: {}",
6645 e.last_line()
6646 );
6647 continue;
6648 }
6649 }
6650 }
6651 let deleted = if force_all {
6652 self.git(&["branch", "-D", &branch]).map(|_| true)
6653 } else {
6654 self.delete_branch_if_safe(&branch)
6655 };
6656 match deleted {
6657 Ok(true) => {
6658 self.forget_branch(&branch);
6659 removed.push(format!("branch {branch}"));
6660 }
6661 Ok(false) => {
6662 logdim!("kept {branch} because its tip or reflog changed before deletion");
6663 }
6664 Err(e) => {
6665 logdim!("could not delete {branch}: {}", e.last_line());
6668 }
6669 }
6670 }
6671 removed
6672 }
6673
6674 fn worktree_is_done(&self, branch: &str) -> bool {
6676 #[derive(Deserialize)]
6677 struct Row {
6678 state: String,
6679 }
6680 let entry = branch
6681 .strip_prefix(self.branch_prefix.as_str())
6682 .unwrap_or(branch);
6683 if let Some(rest) = entry.strip_prefix("pr-") {
6684 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6685 }
6686 if entry.starts_with("issue-") || entry.starts_with("split-") {
6690 let text = self.gh_try(&[
6691 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6692 ]);
6693 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6694 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6695 }
6696 false
6697 }
6698}
6699
6700pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6712 match probe() {
6713 Ok(value) => Ok(value),
6714 Err(first) => {
6715 logdim!("retrying a worktree probe after: {}", first.last_line());
6716 probe()
6717 }
6718 }
6719}
6720
6721pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6727 let root = std::fs::canonicalize(cwd)
6728 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6729 let mut files = BTreeMap::new();
6730 let mut visited = BTreeSet::new();
6731 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6732 Ok(AttributeState { files })
6733}
6734
6735fn collect_attribute_files(
6736 root: &Path,
6737 repository: &Path,
6738 prefix: &Path,
6739 visited: &mut BTreeSet<PathBuf>,
6740 files: &mut BTreeMap<PathBuf, [u8; 32]>,
6741) -> Result<()> {
6742 let canonical = std::fs::canonicalize(repository)
6743 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6744 if !visited.insert(canonical) {
6745 bail!("submodule recursion revisited {}", repository.display());
6746 }
6747 let entries = index_entries(repository)?;
6748 let mut paths: BTreeSet<PathBuf> = entries
6749 .iter()
6750 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6751 .map(|entry| entry.path.clone())
6752 .collect();
6753 paths.extend(untracked_attribute_paths(repository)?);
6759 for path in paths {
6760 let from_root = prefix.join(&path);
6761 let state = attribute_file_fingerprint(&root.join(&from_root))?;
6762 files.insert(from_root, state);
6763 }
6764 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6765 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6766 continue;
6767 };
6768 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6769 }
6770 Ok(())
6771}
6772
6773pub(crate) fn uncertain_worktree_change(
6776 cwd: &Path,
6777 message: impl Into<String>,
6778) -> crate::error::SparError {
6779 let message = message.into();
6780 let marker = write_recovery_marker(cwd, &message);
6781 let note = match marker {
6782 Ok(path) => format!(" Recovery marker: {}.", path.display()),
6783 Err(e) => format!(
6784 " A recovery marker could not be written: {}.",
6785 e.last_line()
6786 ),
6787 };
6788 crate::error::SparError::uncertain_write(format!("{message}{note}"))
6789}
6790
6791fn has_recovery_marker(cwd: &Path) -> bool {
6792 let Ok(entries) = std::fs::read_dir(cwd) else {
6793 return false;
6794 };
6795 entries.flatten().any(|entry| {
6796 entry
6797 .file_name()
6798 .to_str()
6799 .is_some_and(|name| name.starts_with(RECOVERY_MARKER_PREFIX))
6800 })
6801}
6802
6803fn read_checkout_owner(name: &str) -> Option<u32> {
6805 let rest = name.strip_prefix(READ_CHECKOUT_PREFIX)?;
6806 let (pid, serial) = rest.split_once('-')?;
6807 serial.parse::<usize>().ok()?;
6808 pid.parse().ok()
6809}
6810
6811#[cfg(unix)]
6817fn process_is_running(pid: u32) -> bool {
6818 let Ok(pid) = libc::pid_t::try_from(pid) else {
6819 return true;
6820 };
6821 if pid <= 0 {
6822 return true;
6823 }
6824 if unsafe { libc::kill(pid, 0) } == 0 {
6825 return true;
6826 }
6827 std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
6828}
6829
6830#[cfg(not(unix))]
6831fn process_is_running(_pid: u32) -> bool {
6832 true
6833}
6834
6835fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6836 use std::sync::atomic::{AtomicU32, Ordering};
6837 static NEXT: AtomicU32 = AtomicU32::new(0);
6838 for _ in 0..1000 {
6839 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6840 let path = cwd.join(format!(
6841 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6842 std::process::id()
6843 ));
6844 let mut options = OpenOptions::new();
6845 options.write(true).create_new(true);
6846 #[cfg(unix)]
6847 {
6848 use std::os::unix::fs::OpenOptionsExt;
6849 options.mode(0o600);
6850 }
6851 match options.open(&path) {
6852 Ok(mut file) => {
6853 file.write_all(detail.as_bytes())
6854 .and_then(|_| file.write_all(b"\n"))
6855 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6856 return Ok(path);
6857 }
6858 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6859 Err(e) => {
6860 return Err(spar_err!(
6861 "could not create a recovery marker in {}: {e}",
6862 cwd.display()
6863 ))
6864 }
6865 }
6866 }
6867 bail!(
6868 "could not choose a free recovery marker name in {}",
6869 cwd.display()
6870 )
6871}
6872
6873fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6878 let mut argv = vec![
6879 "git".to_string(),
6880 "-c".to_string(),
6881 "maintenance.auto=false".to_string(),
6882 "-c".to_string(),
6883 "gc.auto=0".to_string(),
6884 ];
6885 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6886 argv
6887}
6888
6889fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6890 let mut argv = git_without_maintenance_argv(&[]);
6891 argv.extend([
6892 "-c".to_string(),
6893 "core.fsmonitor=".to_string(),
6894 "-c".to_string(),
6895 "commit.gpgsign=false".to_string(),
6896 "-c".to_string(),
6897 "core.hooksPath=/dev/null".to_string(),
6898 ]);
6899 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6900 argv
6901}
6902
6903pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6910 let root = std::fs::canonicalize(cwd)
6911 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6912 let mut files = BTreeMap::new();
6913 let mut ignored = BTreeSet::new();
6914 let mut visited = BTreeSet::new();
6915 collect_untracked_files(
6916 &root,
6917 &root,
6918 Path::new(""),
6919 &mut visited,
6920 &mut files,
6921 &mut ignored,
6922 )?;
6923 Ok(IgnoredState { files, ignored })
6924}
6925
6926fn collect_untracked_files(
6927 root: &Path,
6928 repository: &Path,
6929 prefix: &Path,
6930 visited: &mut BTreeSet<PathBuf>,
6931 files: &mut BTreeMap<PathBuf, UntrackedFile>,
6932 ignored: &mut BTreeSet<PathBuf>,
6933) -> Result<()> {
6934 let canonical = std::fs::canonicalize(repository)
6935 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6936 if !visited.insert(canonical.clone()) {
6937 bail!("submodule recursion revisited {}", canonical.display());
6938 }
6939 let listed = run_git_bytes(
6943 repository,
6944 &["ls-files", "--others", "--exclude-standard", "-z"],
6945 )?;
6946 if !listed.is_empty() && !listed.ends_with(&[0]) {
6947 bail!(
6948 "git returned an unterminated untracked-file list for {}",
6949 repository.display()
6950 );
6951 }
6952
6953 for raw in listed
6954 .split(|byte| *byte == 0)
6955 .filter(|raw| !raw.is_empty())
6956 {
6957 let (relative, nested) = untracked_record(raw, "untracked")?;
6958 let from_root = prefix.join(&relative);
6959 if is_spar_own_path(&from_root) {
6960 continue;
6961 }
6962 let absolute = root.join(&from_root);
6963 let fingerprint = if nested {
6964 nested_repository_fingerprint(&absolute)?
6965 } else {
6966 ignored_file_fingerprint(&absolute)?
6967 };
6968 if files.insert(from_root.clone(), fingerprint).is_some() {
6969 bail!(
6970 "git returned the untracked path more than once: {:?}",
6971 from_root
6972 );
6973 }
6974 }
6975
6976 let ignored_listed = run_git_bytes(
6977 repository,
6978 &[
6979 "ls-files",
6980 "--others",
6981 "--ignored",
6982 "--exclude-standard",
6983 "-z",
6984 ],
6985 )?;
6986 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6987 bail!(
6988 "git returned an unterminated ignored-file list for {}",
6989 repository.display()
6990 );
6991 }
6992 for raw in ignored_listed
6998 .split(|byte| *byte == 0)
6999 .filter(|raw| !raw.is_empty())
7000 {
7001 let (relative, nested) = untracked_record(raw, "ignored")?;
7002 let from_root = prefix.join(relative);
7003 if is_spar_own_path(&from_root) {
7004 continue;
7005 }
7006 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
7007 let absolute = root.join(&from_root);
7008 let fingerprint = if nested {
7009 nested_repository_fingerprint(&absolute)?
7010 } else {
7011 ignored_file_fingerprint(&absolute)?
7012 };
7013 slot.insert(fingerprint);
7014 }
7015 if !ignored.insert(from_root.clone()) {
7016 bail!(
7017 "git returned the ignored path more than once: {:?}",
7018 from_root
7019 );
7020 }
7021 }
7022
7023 for link in gitlinks(repository)? {
7024 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7025 continue;
7026 };
7027 collect_untracked_files(
7028 root,
7029 &submodule,
7030 &prefix.join(&link.path),
7031 visited,
7032 files,
7033 ignored,
7034 )?;
7035 }
7036 Ok(())
7037}
7038
7039fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
7040 let argv = git_without_automation_argv(args);
7041 proc::run_bytes(
7042 &argv,
7043 &ExecOpts::new()
7044 .cwd(cwd)
7045 .timeout_secs(30)
7046 .stop_descendants(true),
7047 )
7048}
7049
7050fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
7051 let argv = git_without_automation_argv(args);
7052 proc::run(
7053 &argv,
7054 &ExecOpts::new()
7055 .cwd(cwd)
7056 .timeout_secs(30)
7057 .stop_descendants(true),
7058 )
7059}
7060
7061fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
7062 let path = path.to_str().ok_or_else(|| {
7063 spar_err!(
7064 "cannot verify filtered content for a non-UTF-8 path in {}",
7065 cwd.display()
7066 )
7067 })?;
7068 let path_arg = format!("--path={path}");
7069 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
7070 Ok(Sha256::digest(bytes).into())
7071}
7072
7073fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
7074 let relative = path_from_git_bytes(raw)?;
7075 if relative.is_absolute()
7076 || relative.components().any(|component| {
7077 matches!(
7078 component,
7079 std::path::Component::ParentDir
7080 | std::path::Component::RootDir
7081 | std::path::Component::Prefix(_)
7082 )
7083 })
7084 {
7085 bail!("git returned an unsafe {kind} path: {:?}", relative);
7086 }
7087 Ok(relative)
7088}
7089
7090fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
7099 let nested = raw.last() == Some(&b'/');
7100 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
7101 if trimmed.is_empty() {
7102 bail!("git returned an empty {kind} path");
7103 }
7104 Ok((safe_git_path(trimmed, kind)?, nested))
7105}
7106
7107fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
7108 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
7109 if !listed.is_empty() && !listed.ends_with(&[0]) {
7110 bail!(
7111 "git returned an unterminated index listing for {}",
7112 cwd.display()
7113 );
7114 }
7115 let mut entries = Vec::new();
7116 for record in listed
7117 .split(|byte| *byte == 0)
7118 .filter(|record| !record.is_empty())
7119 {
7120 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7121 bail!(
7122 "git returned a malformed index record for {}",
7123 cwd.display()
7124 );
7125 };
7126 let header = &record[..tab];
7127 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
7128 if fields.len() != 3 {
7129 bail!(
7130 "git returned a malformed index header for {}",
7131 cwd.display()
7132 );
7133 }
7134 if fields[2] != b"0" {
7135 continue;
7136 }
7137 let mode = std::str::from_utf8(fields[0])
7138 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
7139 .to_string();
7140 let oid = std::str::from_utf8(fields[1])
7141 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7142 .to_string();
7143 entries.push(IndexEntry {
7144 path: safe_git_path(&record[tab + 1..], "index")?,
7145 mode,
7146 oid,
7147 });
7148 }
7149 Ok(entries)
7150}
7151
7152fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
7154 let listed = run_git_bytes(
7155 cwd,
7156 &[
7157 "ls-files",
7158 "--others",
7159 "--exclude-standard",
7160 "-z",
7161 "--",
7162 ".gitattributes",
7163 ":(glob)**/.gitattributes",
7164 ],
7165 )?;
7166 if !listed.is_empty() && !listed.ends_with(&[0]) {
7167 bail!(
7168 "git returned an unterminated attribute-file listing for {}",
7169 cwd.display()
7170 );
7171 }
7172 listed
7173 .split(|byte| *byte == 0)
7174 .filter(|record| !record.is_empty())
7175 .map(|raw| safe_git_path(raw, "attribute"))
7176 .collect()
7177}
7178
7179fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
7180 if !untracked_attribute_paths(cwd)?.is_empty() {
7181 return Ok(true);
7182 }
7183
7184 let index = index_entries(cwd)?
7185 .into_iter()
7186 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7187 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7188 .collect::<BTreeMap<_, _>>();
7189 let head = tree_entries(cwd, "HEAD")?
7190 .into_iter()
7191 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7192 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7193 .collect::<BTreeMap<_, _>>();
7194 if index != head {
7195 return Ok(true);
7196 }
7197
7198 let effective = check_attributes(cwd, index.keys().cloned())?;
7199 let config = CheckoutConfig::read(cwd)?;
7200 for (path, (_mode, oid)) in index {
7201 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
7202 return Ok(true);
7203 };
7204 let attributes = effective
7205 .get(&path)
7206 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
7207 if allows_expected_crlf(&config, attributes)? {
7208 if worktree.mode == "120000" {
7209 return Ok(true);
7210 }
7211 let (normalized, every_lf_was_crlf) =
7212 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7213 if !every_lf_was_crlf || normalized != oid {
7214 return Ok(true);
7215 }
7216 } else if worktree.raw_oid != oid {
7217 return Ok(true);
7218 }
7219 }
7220 Ok(false)
7221}
7222
7223fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7224 Ok(index_entries(cwd)?
7225 .into_iter()
7226 .filter(|entry| entry.mode == "160000")
7227 .map(|entry| Gitlink {
7228 path: entry.path,
7229 oid: entry.oid,
7230 })
7231 .collect())
7232}
7233
7234fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7235 let mut tracked = BTreeMap::new();
7236 for entry in index_entries(cwd)? {
7237 if entry.mode == "160000" {
7238 continue;
7239 }
7240 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7241 tracked.insert(
7242 entry.path,
7243 TrackedEntry {
7244 index_mode: entry.mode,
7245 index_oid: entry.oid,
7246 worktree,
7247 },
7248 );
7249 }
7250 Ok(tracked)
7251}
7252
7253fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7254 let metadata = match std::fs::symlink_metadata(path) {
7255 Ok(metadata) => metadata,
7256 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7257 Err(e) => {
7258 return Err(spar_err!(
7259 "could not inspect tracked file {}: {e}",
7260 path.display()
7261 ))
7262 }
7263 };
7264 let mut fingerprint = Sha256::new();
7265 if metadata.file_type().is_symlink() {
7266 let target = std::fs::read_link(path)
7267 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7268 let bytes = os_str_bytes(target.as_os_str())?;
7269 fingerprint.update(b"symlink\0");
7270 fingerprint.update(&bytes);
7271 let content = Sha256::digest(&bytes).into();
7272 return Ok(Some(WorktreeFile {
7273 mode: "120000".to_string(),
7274 #[cfg(unix)]
7275 permissions: 0,
7276 raw_oid: git_blob_oid(oid_len, &bytes)?,
7277 fingerprint: fingerprint.finalize().into(),
7278 content,
7279 }));
7280 }
7281 if !metadata.is_file() {
7282 bail!("tracked path {} is not a file or symlink", path.display());
7283 }
7284
7285 let mut options = OpenOptions::new();
7286 options.read(true);
7287 #[cfg(unix)]
7288 {
7289 use std::os::unix::fs::OpenOptionsExt;
7290 options.custom_flags(libc::O_NOFOLLOW);
7291 }
7292 let mut file = options
7293 .open(path)
7294 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7295 let before = file
7296 .metadata()
7297 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7298 let mode = tracked_file_mode(&before);
7299 #[cfg(unix)]
7300 let permissions = {
7301 use std::os::unix::fs::MetadataExt;
7302 before.mode() & 0o7777
7303 };
7304 fingerprint.update(b"file\0");
7305 fingerprint.update(mode.as_bytes());
7306 #[cfg(unix)]
7307 fingerprint.update(permissions.to_le_bytes());
7308 fingerprint.update(before.len().to_le_bytes());
7309 let mut content = Sha256::new();
7310 let header = format!("blob {}\0", before.len());
7311 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7312 let mut buf = [0u8; 64 * 1024];
7313 loop {
7314 let read = file
7315 .read(&mut buf)
7316 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7317 if read == 0 {
7318 break;
7319 }
7320 fingerprint.update(&buf[..read]);
7321 content.update(&buf[..read]);
7322 object.update(&buf[..read]);
7323 }
7324 let after = file
7325 .metadata()
7326 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7327 if before.len() != after.len()
7328 || before.modified().ok() != after.modified().ok()
7329 || before.permissions() != after.permissions()
7330 {
7331 bail!(
7332 "tracked file {} changed while it was being inspected",
7333 path.display()
7334 );
7335 }
7336 let current = std::fs::symlink_metadata(path)
7337 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7338 if !same_file(&after, ¤t) {
7339 bail!(
7340 "tracked file {} was replaced while it was being inspected",
7341 path.display()
7342 );
7343 }
7344 Ok(Some(WorktreeFile {
7345 mode,
7346 #[cfg(unix)]
7347 permissions,
7348 raw_oid: object.finish(),
7349 fingerprint: fingerprint.finalize().into(),
7350 content: content.finalize().into(),
7351 }))
7352}
7353
7354fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7355 let metadata = std::fs::symlink_metadata(path)
7356 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7357 let mut digest = Sha256::new();
7358 if metadata.file_type().is_symlink() {
7359 digest.update(b"symlink\0");
7360 let target = std::fs::read_link(path)
7361 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7362 digest.update(os_str_bytes(target.as_os_str())?);
7363 return Ok(digest.finalize().into());
7364 }
7365 if !metadata.is_file() {
7366 bail!("attribute path {} is not a file or symlink", path.display());
7367 }
7368 let mut options = OpenOptions::new();
7369 options.read(true);
7370 #[cfg(unix)]
7371 {
7372 use std::os::unix::fs::OpenOptionsExt;
7373 options.custom_flags(libc::O_NOFOLLOW);
7374 }
7375 let mut file = options
7376 .open(path)
7377 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7378 let before = file
7379 .metadata()
7380 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7381 digest.update(b"file\0");
7382 let mut buf = [0u8; 64 * 1024];
7383 loop {
7384 let read = file
7385 .read(&mut buf)
7386 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7387 if read == 0 {
7388 break;
7389 }
7390 digest.update(&buf[..read]);
7391 }
7392 let after = file
7393 .metadata()
7394 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7395 let current = std::fs::symlink_metadata(path)
7396 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7397 if before.len() != after.len()
7398 || before.modified().ok() != after.modified().ok()
7399 || !same_file(&after, ¤t)
7400 {
7401 bail!(
7402 "attribute file {} changed while it was being inspected",
7403 path.display()
7404 );
7405 }
7406 Ok(digest.finalize().into())
7407}
7408
7409enum ObjectHasher {
7410 Sha1(Sha1),
7411 Sha256(Sha256),
7412}
7413
7414impl ObjectHasher {
7415 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7416 let mut hasher = match oid_len {
7417 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7418 64 => Self::Sha256(Sha256::new()),
7419 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7420 };
7421 hasher.update(header);
7422 Ok(hasher)
7423 }
7424
7425 fn update(&mut self, bytes: &[u8]) {
7426 match self {
7427 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7428 Self::Sha256(hasher) => hasher.update(bytes),
7429 }
7430 }
7431
7432 fn finish(self) -> String {
7433 let bytes = match self {
7434 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7435 Self::Sha256(hasher) => hasher.finalize().to_vec(),
7436 };
7437 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7438 }
7439}
7440
7441fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7442 let header = format!("blob {}\0", bytes.len());
7443 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7444 hasher.update(bytes);
7445 Ok(hasher.finish())
7446}
7447
7448fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7449 let mut first = open_regular_file(path)?;
7450 let first_before = first
7451 .metadata()
7452 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7453 let mut raw_len = 0u64;
7454 let mut crlf_pairs = 0u64;
7455 let mut previous_was_cr = false;
7456 let mut every_lf_was_crlf = true;
7457 let mut buf = [0u8; 64 * 1024];
7458 loop {
7459 let read = first
7460 .read(&mut buf)
7461 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7462 if read == 0 {
7463 break;
7464 }
7465 raw_len = raw_len
7466 .checked_add(read as u64)
7467 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7468 for byte in &buf[..read] {
7469 if *byte == b'\n' {
7470 if previous_was_cr {
7471 crlf_pairs += 1;
7472 } else {
7473 every_lf_was_crlf = false;
7474 }
7475 }
7476 previous_was_cr = *byte == b'\r';
7477 }
7478 }
7479 let first_after = first
7480 .metadata()
7481 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7482 let current = std::fs::symlink_metadata(path)
7483 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7484 if raw_len != first_before.len()
7485 || !stable_file_metadata(&first_before, &first_after)
7486 || !stable_file_metadata(&first_after, ¤t)
7487 {
7488 bail!(
7489 "tracked file {} changed while line endings were inspected",
7490 path.display()
7491 );
7492 }
7493
7494 let normalized_len = raw_len
7495 .checked_sub(crlf_pairs)
7496 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7497 let header = format!("blob {normalized_len}\0");
7498 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7499 let mut second = open_regular_file(path)?;
7500 let second_before = second
7501 .metadata()
7502 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7503 if !stable_file_metadata(&first_after, &second_before) {
7504 bail!(
7505 "tracked file {} changed between line-ending checks",
7506 path.display()
7507 );
7508 }
7509 let mut pending_cr = false;
7510 loop {
7511 let read = second
7512 .read(&mut buf)
7513 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7514 if read == 0 {
7515 break;
7516 }
7517 for byte in &buf[..read] {
7518 if pending_cr {
7519 if *byte == b'\n' {
7520 object.update(b"\n");
7521 pending_cr = false;
7522 continue;
7523 }
7524 object.update(b"\r");
7525 pending_cr = false;
7526 }
7527 if *byte == b'\r' {
7528 pending_cr = true;
7529 } else {
7530 object.update(std::slice::from_ref(byte));
7531 }
7532 }
7533 }
7534 if pending_cr {
7535 object.update(b"\r");
7536 }
7537 let second_after = second
7538 .metadata()
7539 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7540 let current = std::fs::symlink_metadata(path)
7541 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7542 if !stable_file_metadata(&second_before, &second_after)
7543 || !stable_file_metadata(&second_after, ¤t)
7544 {
7545 bail!(
7546 "tracked file {} changed while line endings were hashed",
7547 path.display()
7548 );
7549 }
7550 Ok((object.finish(), every_lf_was_crlf))
7551}
7552
7553fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7554 let mut options = OpenOptions::new();
7555 options.read(true);
7556 #[cfg(unix)]
7557 {
7558 use std::os::unix::fs::OpenOptionsExt;
7559 options.custom_flags(libc::O_NOFOLLOW);
7560 }
7561 let file = options
7562 .open(path)
7563 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7564 let metadata = file
7565 .metadata()
7566 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7567 if !metadata.is_file() {
7568 bail!("tracked path {} is not a regular file", path.display());
7569 }
7570 Ok(file)
7571}
7572
7573fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7574 if !same_file(left, right)
7575 || left.len() != right.len()
7576 || left.modified().ok() != right.modified().ok()
7577 || left.permissions() != right.permissions()
7578 {
7579 return false;
7580 }
7581 #[cfg(unix)]
7582 {
7583 use std::os::unix::fs::MetadataExt;
7584 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7585 }
7586 #[cfg(not(unix))]
7587 {
7588 left.created().ok() == right.created().ok()
7589 }
7590}
7591
7592const ATTRIBUTE_BATCH: usize = 1000;
7599
7600fn check_attributes(
7601 cwd: &Path,
7602 paths: impl IntoIterator<Item = PathBuf>,
7603) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7604 let paths = paths.into_iter().collect::<BTreeSet<_>>();
7605 if paths.is_empty() {
7606 return Ok(BTreeMap::new());
7607 }
7608 let ordered = paths.into_iter().collect::<Vec<_>>();
7609 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7610 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7611 values.extend(check_attribute_batch(cwd, batch)?);
7612 }
7613 Ok(values)
7614}
7615
7616fn check_attribute_batch(
7617 cwd: &Path,
7618 batch: &[PathBuf],
7619) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7620 const NAMES: [&str; 6] = [
7621 "filter",
7622 "working-tree-encoding",
7623 "ident",
7624 "text",
7625 "eol",
7626 "crlf",
7627 ];
7628 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7629 let mut input = String::new();
7630 for path in &paths {
7631 let path = path.to_str().ok_or_else(|| {
7632 spar_err!(
7633 "cannot inspect attributes for a non-UTF-8 path in {}",
7634 cwd.display()
7635 )
7636 })?;
7637 input.push_str(path);
7638 input.push('\0');
7639 }
7640 let argv = git_without_automation_argv(&[
7641 "check-attr",
7642 "-z",
7643 "--cached",
7644 "--stdin",
7645 "filter",
7646 "working-tree-encoding",
7647 "ident",
7648 "text",
7649 "eol",
7650 "crlf",
7651 ]);
7652 let output = proc::run_bytes(
7653 &argv,
7654 &ExecOpts::new()
7655 .cwd(cwd)
7656 .timeout_secs(30)
7657 .stdin(input)
7658 .stop_descendants(true),
7659 )?;
7660 if !output.is_empty() && !output.ends_with(&[0]) {
7661 bail!(
7662 "git returned an unterminated attribute result for {}",
7663 cwd.display()
7664 );
7665 }
7666 let fields = output
7667 .split(|byte| *byte == 0)
7668 .filter(|field| !field.is_empty())
7669 .collect::<Vec<_>>();
7670 if fields.len() != paths.len() * NAMES.len() * 3 {
7671 bail!(
7672 "git returned an unexpected attribute result for {}",
7673 cwd.display()
7674 );
7675 }
7676 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7677 for record in fields.chunks_exact(3) {
7678 let path = safe_git_path(record[0], "attribute")?;
7679 if !paths.contains(&path) {
7680 bail!(
7681 "git returned attributes for the wrong path in {}",
7682 cwd.display()
7683 );
7684 }
7685 let name = std::str::from_utf8(record[1])
7686 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7687 let value = std::str::from_utf8(record[2])
7688 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7689 values
7690 .entry(path)
7691 .or_default()
7692 .insert(name.to_string(), value.to_string());
7693 }
7694 if paths.iter().any(|path| {
7695 values
7696 .get(path)
7697 .is_none_or(|attributes| attributes.len() != NAMES.len())
7698 }) {
7699 bail!(
7700 "git omitted an attribute result for a tracked path in {}",
7701 cwd.display()
7702 );
7703 }
7704 Ok(values)
7705}
7706
7707fn attribute_is_active(value: Option<&String>) -> bool {
7708 !matches!(
7709 value.map(String::as_str),
7710 None | Some("unspecified") | Some("unset")
7711 )
7712}
7713
7714fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7715 attribute_is_active(values.get("filter"))
7716 || attribute_is_active(values.get("working-tree-encoding"))
7717}
7718
7719struct CheckoutConfig {
7728 autocrlf: Option<String>,
7729 eol: Option<String>,
7730 symlinks: Option<bool>,
7731}
7732
7733impl CheckoutConfig {
7734 fn read(cwd: &Path) -> Result<Self> {
7735 Ok(Self {
7736 autocrlf: git_config_value(cwd, "core.autocrlf")?,
7737 eol: git_config_value(cwd, "core.eol")?,
7738 symlinks: git_config_bool(cwd, "core.symlinks")?,
7739 })
7740 }
7741
7742 fn autocrlf_is_true(&self) -> bool {
7743 self.autocrlf.as_ref().is_some_and(|value| {
7744 matches!(
7745 value.to_ascii_lowercase().as_str(),
7746 "true" | "yes" | "on" | "1"
7747 )
7748 })
7749 }
7750
7751 fn eol_is(&self, wanted: &str) -> bool {
7752 self.eol
7753 .as_ref()
7754 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7755 }
7756}
7757
7758fn path_has_ambiguous_transform(
7759 config: &CheckoutConfig,
7760 values: &BTreeMap<String, String>,
7761) -> Result<bool> {
7762 if path_has_external_transform(values)
7763 || attribute_is_active(values.get("ident"))
7764 || attribute_is_active(values.get("crlf"))
7765 {
7766 return Ok(true);
7767 }
7768 let text = values.get("text").map(String::as_str);
7769 let eol = values.get("eol").map(String::as_str);
7770 if text == Some("auto") {
7771 return Ok(true);
7772 }
7773 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7774 || !matches!(
7775 eol,
7776 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7777 )
7778 {
7779 return Ok(true);
7780 }
7781 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7782 return Ok(config.autocrlf_is_true());
7783 }
7784 Ok(false)
7785}
7786
7787fn allows_expected_crlf(
7788 config: &CheckoutConfig,
7789 values: &BTreeMap<String, String>,
7790) -> Result<bool> {
7791 if path_has_external_transform(values)
7792 || attribute_is_active(values.get("ident"))
7793 || attribute_is_active(values.get("crlf"))
7794 {
7795 return Ok(false);
7796 }
7797 let text = values.get("text").map(String::as_str);
7798 let eol = values.get("eol").map(String::as_str);
7799 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7800 return Ok(false);
7801 }
7802 if eol == Some("crlf") {
7803 return Ok(true);
7804 }
7805 if text != Some("set") {
7806 return Ok(false);
7807 }
7808 if let Some(autocrlf) = config.autocrlf.as_deref() {
7809 match autocrlf.to_ascii_lowercase().as_str() {
7810 "true" | "yes" | "on" | "1" => return Ok(true),
7811 "input" => return Ok(false),
7812 _ => {}
7813 }
7814 }
7815 if config.eol_is("crlf") {
7816 return Ok(true);
7817 }
7818 #[cfg(windows)]
7819 if config.eol.is_none() || config.eol_is("native") {
7820 return Ok(true);
7821 }
7822 Ok(false)
7823}
7824
7825fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7826 let argv = git_without_automation_argv(&["config", "--get", key]);
7827 let output = proc::exec(
7828 &argv,
7829 &ExecOpts::new()
7830 .cwd(cwd)
7831 .timeout_secs(30)
7832 .check(false)
7833 .stop_descendants(true),
7834 )?;
7835 match output.code {
7836 0 => Ok(Some(output.stdout.trim().to_string())),
7837 1 => Ok(None),
7838 _ => bail!(
7839 "could not read Git configuration in {}: {}",
7840 cwd.display(),
7841 output.stderr.trim()
7842 ),
7843 }
7844}
7845
7846fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7847 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7848 let output = proc::exec(
7849 &argv,
7850 &ExecOpts::new()
7851 .cwd(cwd)
7852 .timeout_secs(30)
7853 .check(false)
7854 .stop_descendants(true),
7855 )?;
7856 match output.code {
7857 0 if output.stdout.trim() == "true" => Ok(Some(true)),
7858 0 if output.stdout.trim() == "false" => Ok(Some(false)),
7859 0 => bail!(
7860 "git returned an invalid boolean for {key} in {}",
7861 cwd.display()
7862 ),
7863 1 => Ok(None),
7864 _ => bail!(
7865 "could not read Git configuration in {}: {}",
7866 cwd.display(),
7867 output.stderr.trim()
7868 ),
7869 }
7870}
7871
7872#[cfg(unix)]
7873fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7874 use std::os::unix::fs::PermissionsExt;
7875 if metadata.permissions().mode() & 0o111 == 0 {
7876 "100644".to_string()
7877 } else {
7878 "100755".to_string()
7879 }
7880}
7881
7882#[cfg(not(unix))]
7883fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7884 "100644".to_string()
7885}
7886
7887fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7888 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7889 if !listed.is_empty() && !listed.ends_with(&[0]) {
7890 bail!(
7891 "git returned an unterminated tree listing for {}",
7892 cwd.display()
7893 );
7894 }
7895 let mut entries = Vec::new();
7896 for record in listed
7897 .split(|byte| *byte == 0)
7898 .filter(|record| !record.is_empty())
7899 {
7900 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7901 bail!("git returned a malformed tree record for {}", cwd.display());
7902 };
7903 let fields = record[..tab]
7904 .split(|byte| *byte == b' ')
7905 .collect::<Vec<_>>();
7906 if fields.len() != 3 {
7907 bail!("git returned a malformed tree header for {}", cwd.display());
7908 }
7909 let mode = std::str::from_utf8(fields[0])
7910 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7911 .to_string();
7912 let oid = std::str::from_utf8(fields[2])
7913 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7914 .to_string();
7915 entries.push(IndexEntry {
7916 path: safe_git_path(&record[tab + 1..], "tree")?,
7917 mode,
7918 oid,
7919 });
7920 }
7921 Ok(entries)
7922}
7923
7924fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7925 Ok(tree_entries(cwd, "HEAD")?
7926 .into_iter()
7927 .filter(|entry| entry.mode == "160000")
7928 .map(|entry| (entry.path, entry.oid))
7929 .collect())
7930}
7931
7932fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7933 let head = head_gitlinks(cwd)?;
7934 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7935 .into_iter()
7936 .map(|link| (link.path, link.oid))
7937 .collect();
7938 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7939 paths.extend(index.keys().cloned());
7940 Ok(paths
7941 .into_iter()
7942 .filter(|path| head.get(path) != index.get(path))
7943 .collect())
7944}
7945
7946fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7947 let path = parent.join(relative);
7948 let metadata = match std::fs::symlink_metadata(&path) {
7949 Ok(metadata) => metadata,
7950 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7951 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7952 };
7953 if !metadata.is_dir() {
7954 bail!("the gitlink at {} is not a directory", path.display());
7955 }
7956 let canonical = std::fs::canonicalize(&path)
7957 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7958 if canonical != path {
7959 bail!(
7960 "the gitlink at {} resolves through a symlink",
7961 path.display()
7962 );
7963 }
7964 if !path.join(".git").exists() {
7965 let empty = std::fs::read_dir(&path)
7966 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7967 .next()
7968 .is_none();
7969 if empty {
7970 return Ok(None);
7971 }
7972 bail!(
7973 "the uninitialized gitlink at {} contains local files",
7974 path.display()
7975 );
7976 }
7977 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7978 if inside.trim() != "true" {
7979 bail!("the gitlink at {} is not a worktree", path.display());
7980 }
7981 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7982 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7983 spar_err!(
7984 "could not resolve the gitlink top level at {}: {e}",
7985 path.display()
7986 )
7987 })?;
7988 if top != canonical {
7989 bail!(
7990 "the gitlink at {} belongs to a different worktree",
7991 path.display()
7992 );
7993 }
7994 Ok(Some(canonical))
7995}
7996
7997fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
8011 let root = std::fs::canonicalize(cwd)
8012 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8013 let mut allowed = BTreeSet::from([root.join(".git")]);
8014 let mut checkouts = BTreeSet::new();
8015 let mut repositories = vec![root.clone()];
8016 let mut visited = BTreeSet::new();
8017 while let Some(repository) = repositories.pop() {
8018 let canonical = std::fs::canonicalize(&repository)
8019 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8020 if !visited.insert(canonical.clone()) {
8021 bail!("submodule recursion revisited {}", canonical.display());
8022 }
8023 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
8024 let (path, nested) = untracked_record(&raw, "untracked")?;
8025 if nested {
8026 checkouts.insert(canonical.join(path));
8027 }
8028 }
8029 for link in gitlinks(&canonical)? {
8030 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
8031 continue;
8032 };
8033 allowed.insert(submodule.join(".git"));
8034 repositories.push(submodule);
8035 }
8036 }
8037
8038 let scan_root = root.clone();
8042 let mut directories = vec![root];
8043 while let Some(directory) = directories.pop() {
8044 let entries = match std::fs::read_dir(&directory) {
8045 Ok(entries) => entries,
8046 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8047 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8048 };
8049 for entry in entries {
8050 let entry = match entry {
8051 Ok(entry) => entry,
8052 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8053 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8054 };
8055 let path = entry.path();
8056 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
8057 continue;
8058 }
8059 if entry.file_name() == OsStr::new(".git") {
8060 if !allowed.contains(&path) {
8061 return Ok(Some(path));
8062 }
8063 continue;
8064 }
8065 let kind = match entry.file_type() {
8066 Ok(kind) => kind,
8067 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8068 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8069 };
8070 if kind.is_dir() && !checkouts.contains(&path) {
8071 directories.push(path);
8072 }
8073 }
8074 }
8075 Ok(None)
8076}
8077
8078pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
8079 if let Some(path) = unexpected_nested_git_entry(cwd)? {
8080 bail!(
8081 "the worktree contains a Git entry at {} that Git does not report as a nested \
8082 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
8083 prove they are unchanged.",
8084 path.display()
8085 );
8086 }
8087 let root = std::fs::canonicalize(cwd)
8088 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8089 let mut repositories = BTreeMap::new();
8090 let mut visited = BTreeSet::new();
8091 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
8092 Ok(GitState { repositories })
8093}
8094
8095fn collect_git_state(
8096 repository: &Path,
8097 prefix: &Path,
8098 visited: &mut BTreeSet<PathBuf>,
8099 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
8100) -> Result<()> {
8101 let canonical = std::fs::canonicalize(repository)
8102 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8103 if !visited.insert(canonical.clone()) {
8104 bail!("submodule recursion revisited {}", canonical.display());
8105 }
8106 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8107 let head = head.trim().to_string();
8108 if head.is_empty() {
8109 bail!("git returned an empty head for {}", repository.display());
8110 }
8111 let unsafe_index_flags = unsafe_index_flags(repository)?;
8112 let tracked = tracked_entries(repository)?;
8113 let gitlinks = gitlinks(repository)?;
8114 if repositories
8115 .insert(
8116 prefix.to_path_buf(),
8117 RepositoryState {
8118 head,
8119 unsafe_index_flags,
8120 tracked,
8121 gitlinks: gitlinks
8122 .iter()
8123 .map(|link| (link.path.clone(), link.oid.clone()))
8124 .collect(),
8125 },
8126 )
8127 .is_some()
8128 {
8129 bail!("Git state contains duplicate repository path {:?}", prefix);
8130 }
8131
8132 for link in gitlinks {
8133 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
8134 continue;
8135 };
8136 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
8137 }
8138 Ok(())
8139}
8140
8141fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
8142 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
8143 if !listed.is_empty() && !listed.ends_with(&[0]) {
8144 bail!(
8145 "git returned an unterminated index-flag listing for {}",
8146 cwd.display()
8147 );
8148 }
8149 let mut unsafe_records = Vec::new();
8150 for record in listed
8151 .split(|byte| *byte == 0)
8152 .filter(|record| !record.is_empty())
8153 {
8154 if record.len() < 3 || record[1] != b' ' {
8155 bail!(
8156 "git returned a malformed index-flag record for {}",
8157 cwd.display()
8158 );
8159 }
8160 if record[0] != b'H' {
8161 unsafe_records.extend_from_slice(record);
8162 unsafe_records.push(0);
8163 }
8164 }
8165 Ok(unsafe_records)
8166}
8167
8168pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
8169 safe_git_state(cwd).map(|_| ())
8170}
8171
8172pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
8173 let state = git_state(cwd)?;
8174 if let Some((path, _repository)) = state
8175 .repositories
8176 .iter()
8177 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
8178 {
8179 let label = if path.as_os_str().is_empty() {
8180 cwd.to_path_buf()
8181 } else {
8182 cwd.join(path)
8183 };
8184 bail!(
8185 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
8186 SPAR cannot prove the working files are unchanged, so it was kept.",
8187 label.display()
8188 );
8189 }
8190 Ok(state)
8191}
8192
8193fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
8194 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
8195}
8196
8197fn repository_has_recoverable_work_with(
8198 cwd: &Path,
8199 include_ignored: bool,
8200 disposable: Disposable,
8201) -> Result<bool> {
8202 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
8203 return Ok(true);
8204 }
8205 let mut visited = BTreeSet::new();
8206 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
8207}
8208
8209fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
8210 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
8211 let git_dir = PathBuf::from(git_dir.trim());
8212 let git_dir = if git_dir.is_absolute() {
8213 git_dir
8214 } else {
8215 cwd.join(git_dir)
8216 };
8217 let git_dir = std::fs::canonicalize(&git_dir)
8218 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8219 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8220 Ok(_) => return Ok(true),
8221 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8222 Err(error) => {
8223 return Err(spar_err!(
8224 "could not inspect per-worktree configuration in {}: {error}",
8225 git_dir.display()
8226 ))
8227 }
8228 }
8229
8230 let orig_head = git_dir.join("ORIG_HEAD");
8231 match std::fs::symlink_metadata(&orig_head) {
8232 Ok(metadata) if metadata.is_file() => {
8233 let oid = std::fs::read_to_string(&orig_head)
8234 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8235 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8236 return Ok(true);
8237 };
8238 if !commit_has_shared_ref(cwd, &commit)? {
8239 return Ok(true);
8240 }
8241 }
8242 Ok(_) => return Ok(true),
8243 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8244 Err(error) => {
8245 return Err(spar_err!(
8246 "could not inspect {}: {error}",
8247 orig_head.display()
8248 ))
8249 }
8250 }
8251
8252 let edit_message = git_dir.join("COMMIT_EDITMSG");
8253 match std::fs::symlink_metadata(&edit_message) {
8254 Ok(metadata) if metadata.is_file() => {
8255 let draft = std::fs::read(&edit_message)
8256 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8257 if draft != head_commit_message(cwd)? {
8258 return Ok(true);
8259 }
8260 }
8261 Ok(_) => return Ok(true),
8262 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8263 Err(error) => {
8264 return Err(spar_err!(
8265 "could not inspect {}: {error}",
8266 edit_message.display()
8267 ))
8268 }
8269 }
8270
8271 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8272 return Ok(true);
8273 }
8274
8275 let local_refs = run_git_bytes(
8276 cwd,
8277 &[
8278 "for-each-ref",
8279 "--format=%(refname)",
8280 "refs/worktree",
8281 "refs/bisect",
8282 "refs/rewritten",
8283 ],
8284 )?;
8285 if !local_refs.is_empty() {
8286 return Ok(true);
8287 }
8288
8289 for entry in std::fs::read_dir(&git_dir)
8293 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8294 {
8295 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8296 let known = matches!(
8297 entry.file_name().to_str(),
8298 Some(
8299 "HEAD"
8300 | "ORIG_HEAD"
8301 | "COMMIT_EDITMSG"
8302 | "FETCH_HEAD"
8303 | "commondir"
8304 | "gitdir"
8305 | "index"
8306 | "logs"
8307 | "refs"
8308 )
8309 );
8310 if !known {
8311 return Ok(true);
8312 }
8313 }
8314
8315 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8316 if !commit_has_shared_ref(cwd, head.trim())? {
8317 return Ok(true);
8318 }
8319 Ok(false)
8320}
8321
8322fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8323 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8324 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8325 bail!(
8326 "git returned a commit without a message separator in {}",
8327 cwd.display()
8328 );
8329 };
8330 Ok(commit[split + 2..].to_vec())
8331}
8332
8333fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8334 let metadata = match std::fs::symlink_metadata(logs) {
8335 Ok(metadata) => metadata,
8336 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8337 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8338 };
8339 if !metadata.is_dir() {
8340 return Ok(true);
8341 }
8342 let mut files = Vec::new();
8343 let mut directories = vec![logs.to_path_buf()];
8344 while let Some(directory) = directories.pop() {
8345 for entry in std::fs::read_dir(&directory)
8346 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8347 {
8348 let entry =
8349 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8350 let path = entry.path();
8351 let kind = entry
8352 .file_type()
8353 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8354 if kind.is_dir() {
8355 directories.push(path);
8356 } else if kind.is_file() {
8357 files.push(path);
8358 } else {
8359 return Ok(true);
8360 }
8361 }
8362 }
8363
8364 let mut commits = BTreeSet::new();
8365 for path in files {
8366 if !collect_reflog_commits(cwd, &path, &mut commits)? {
8367 return Ok(true);
8368 }
8369 }
8370 for commit in commits {
8371 if !commit_has_shared_ref(cwd, &commit)? {
8372 return Ok(true);
8373 }
8374 }
8375 Ok(false)
8376}
8377
8378fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8382 let common = common_git_dir(cwd)?;
8383 let reflog = common.join("logs").join(refname);
8384 let metadata = match std::fs::symlink_metadata(&reflog) {
8385 Ok(metadata) => metadata,
8386 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8387 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8388 };
8389 if !metadata.is_file() {
8390 return Ok(false);
8391 }
8392 let mut commits = BTreeSet::new();
8393 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8394 return Ok(false);
8395 }
8396 for commit in commits {
8397 if is_ancestor(cwd, &commit, durable_tip)?
8398 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8399 {
8400 continue;
8401 }
8402 return Ok(false);
8403 }
8404 Ok(true)
8405}
8406
8407fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8408 let file = std::fs::File::open(path)
8409 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8410 for line in std::io::BufReader::new(file).lines() {
8411 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8412 let mut fields = line.splitn(3, ' ');
8413 let Some(old) = fields.next() else {
8414 return Ok(false);
8415 };
8416 let Some(new) = fields.next() else {
8417 return Ok(false);
8418 };
8419 if fields.next().is_none() {
8420 return Ok(false);
8421 }
8422 for oid in [old, new] {
8423 if oid.bytes().all(|byte| byte == b'0') {
8424 continue;
8425 }
8426 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8427 return Ok(false);
8428 };
8429 commits.insert(commit);
8430 }
8431 }
8432 Ok(true)
8433}
8434
8435fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8436 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8437 let path = PathBuf::from(raw.trim());
8438 let path = if path.is_absolute() {
8439 path
8440 } else {
8441 cwd.join(path)
8442 };
8443 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8444}
8445
8446fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8447 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8448 let output = proc::exec(
8449 &argv,
8450 &ExecOpts::new()
8451 .cwd(cwd)
8452 .timeout_secs(30)
8453 .check(false)
8454 .stop_descendants(true),
8455 )?;
8456 match output.code {
8457 0 => Ok(true),
8458 1 => Ok(false),
8459 _ => bail!("{}", proc::failure_message(&argv, &output)),
8460 }
8461}
8462
8463fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8464 let commit = format!("{oid}^{{commit}}");
8465 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8466 let output = proc::exec(
8467 &argv,
8468 &ExecOpts::new()
8469 .cwd(cwd)
8470 .timeout_secs(30)
8471 .check(false)
8472 .stop_descendants(true),
8473 )?;
8474 if output.code != 0 {
8475 return Ok(None);
8476 }
8477 let oid = output.stdout.trim();
8478 if oid.is_empty() {
8479 return Ok(None);
8480 }
8481 Ok(Some(oid.to_string()))
8482}
8483
8484fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8485 commit_has_shared_ref_except(cwd, oid, None)
8486}
8487
8488fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8489 let contains = format!("--contains={oid}");
8490 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8491 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8492 !record.is_empty()
8493 && !record.starts_with(b"refs/worktree/")
8494 && !record.starts_with(b"refs/bisect/")
8495 && !record.starts_with(b"refs/rewritten/")
8496 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8497 }))
8498}
8499
8500fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8515 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8516 for raw in &ordinary {
8517 let (path, _) = untracked_record(raw, "untracked")?;
8518 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8519 continue;
8520 }
8521 return Ok(true);
8522 }
8523 let listed = untracked_listing(
8524 cwd,
8525 &[
8526 "ls-files",
8527 "--others",
8528 "--ignored",
8529 "--exclude-standard",
8530 "-z",
8531 ],
8532 )?;
8533 for raw in listed {
8534 let (path, nested) = untracked_record(&raw, "ignored")?;
8535 if nested {
8539 return Ok(true);
8540 }
8541 if disposable == Disposable::BuildOutput
8542 && !is_generated_artifact(&path)
8543 && !is_desktop_metadata(&path)
8544 {
8545 return Ok(true);
8546 }
8547 }
8548 Ok(false)
8549}
8550
8551fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8552 let listed = run_git_bytes(cwd, args)?;
8553 if !listed.is_empty() && !listed.ends_with(&[0]) {
8554 bail!(
8555 "git returned an unterminated untracked-file list for {}",
8556 cwd.display()
8557 );
8558 }
8559 Ok(listed
8560 .split(|byte| *byte == 0)
8561 .filter(|raw| !raw.is_empty())
8562 .map(|raw| raw.to_vec())
8563 .collect())
8564}
8565
8566fn repository_has_recoverable_work_inner(
8567 cwd: &Path,
8568 include_ignored: bool,
8569 disposable: Disposable,
8570 visited: &mut BTreeSet<PathBuf>,
8571) -> Result<bool> {
8572 let canonical = std::fs::canonicalize(cwd)
8573 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8574 if !visited.insert(canonical.clone()) {
8575 bail!("submodule recursion revisited {}", canonical.display());
8576 }
8577 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8578 return Ok(true);
8579 }
8580 if !unsafe_index_flags(cwd)?.is_empty() {
8581 return Ok(true);
8582 }
8583 if attributes_may_be_modified(cwd)? {
8584 return Ok(true);
8585 }
8586 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8587 return Ok(true);
8588 }
8589 if include_ignored {
8590 let index = index_entries(cwd)?
8591 .into_iter()
8592 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8593 .collect::<BTreeMap<_, _>>();
8594 let head = tree_entries(cwd, "HEAD")?
8595 .into_iter()
8596 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8597 .collect::<BTreeMap<_, _>>();
8598 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8599 return Ok(true);
8600 }
8601 let tracked = tracked_entries(cwd)?;
8602 let effective = check_attributes(cwd, tracked.keys().cloned())?;
8603 let config = CheckoutConfig::read(cwd)?;
8604 for (path, entry) in tracked {
8605 let Some(worktree) = entry.worktree else {
8606 return Ok(true);
8607 };
8608 let attributes = effective.get(&path).ok_or_else(|| {
8609 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8610 })?;
8611 if path_has_ambiguous_transform(&config, attributes)? {
8612 return Ok(true);
8613 }
8614 let symlink_file = entry.index_mode == "120000"
8615 && worktree.mode == "100644"
8616 && worktree.raw_oid == entry.index_oid
8617 && config.symlinks == Some(false);
8618 if worktree.mode != entry.index_mode && !symlink_file {
8619 return Ok(true);
8620 }
8621 if entry.index_mode == "120000" {
8622 if worktree.raw_oid != entry.index_oid {
8623 return Ok(true);
8624 }
8625 continue;
8626 }
8627 #[cfg(unix)]
8628 {
8629 let expected = if entry.index_mode == "100755" {
8630 0o755
8631 } else {
8632 0o644
8633 };
8634 if worktree.permissions != expected {
8635 return Ok(true);
8636 }
8637 }
8638 if allows_expected_crlf(&config, attributes)? {
8639 let (normalized, every_lf_was_crlf) =
8640 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8641 if !every_lf_was_crlf || normalized != entry.index_oid {
8642 return Ok(true);
8643 }
8644 } else if worktree.raw_oid != entry.index_oid {
8645 return Ok(true);
8646 }
8647 }
8648 } else {
8649 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8650 if !run_git_bytes(cwd, &args)?.is_empty() {
8651 return Ok(true);
8652 }
8653 }
8654 for link in gitlinks(cwd)? {
8655 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8656 continue;
8657 };
8658 if include_ignored {
8663 return Ok(true);
8664 }
8665 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8666 if head.trim() != link.oid {
8667 return Ok(true);
8668 }
8669 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8670 {
8671 return Ok(true);
8672 }
8673 }
8674 Ok(false)
8675}
8676
8677pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8678 repository_has_recoverable_work(cwd, false)
8679}
8680
8681fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8682 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8683 Ok(!run_git_bytes(cwd, &args)?.is_empty())
8684}
8685
8686#[cfg(unix)]
8687fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8688 use std::os::unix::ffi::OsStringExt;
8689 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8690}
8691
8692#[cfg(not(unix))]
8693fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8694 String::from_utf8(raw.to_vec())
8695 .map(PathBuf::from)
8696 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8697}
8698
8699fn vanished_untracked_file() -> UntrackedFile {
8715 UntrackedFile {
8716 kind: 0,
8717 len: 0,
8718 modified: None,
8719 created: None,
8720 readonly: false,
8721 symlink_target: None,
8722 #[cfg(unix)]
8723 device: 0,
8724 #[cfg(unix)]
8725 inode: 0,
8726 #[cfg(unix)]
8727 mode: 0,
8728 #[cfg(unix)]
8729 change_seconds: 0,
8730 #[cfg(unix)]
8731 change_nanoseconds: 0,
8732 }
8733}
8734
8735fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8736 let metadata = match std::fs::symlink_metadata(path) {
8737 Ok(metadata) => metadata,
8738 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8739 Err(e) => {
8740 return Err(spar_err!(
8741 "could not inspect the nested repository at {}: {e}",
8742 path.display()
8743 ))
8744 }
8745 };
8746 if !metadata.is_dir() {
8747 bail!(
8748 "git reported {} as a nested repository, but it is not a directory",
8749 path.display()
8750 );
8751 }
8752 if std::fs::symlink_metadata(path.join(".git")).is_err() {
8753 bail!(
8754 "git reported {} as a nested repository, but it has no Git entry",
8755 path.display()
8756 );
8757 }
8758 #[cfg(unix)]
8759 {
8760 use std::os::unix::fs::MetadataExt;
8761 Ok(UntrackedFile {
8762 kind: 3,
8763 len: 0,
8764 modified: None,
8765 created: metadata.created().ok(),
8766 readonly: metadata.permissions().readonly(),
8767 symlink_target: None,
8768 device: metadata.dev(),
8769 inode: metadata.ino(),
8770 mode: metadata.mode(),
8771 change_seconds: 0,
8772 change_nanoseconds: 0,
8773 })
8774 }
8775 #[cfg(not(unix))]
8776 {
8777 Ok(UntrackedFile {
8778 kind: 3,
8779 len: 0,
8780 modified: None,
8781 created: metadata.created().ok(),
8782 readonly: metadata.permissions().readonly(),
8783 symlink_target: None,
8784 })
8785 }
8786}
8787
8788fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8789 let metadata = match std::fs::symlink_metadata(path) {
8790 Ok(metadata) => metadata,
8791 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8792 Err(e) => {
8793 return Err(spar_err!(
8794 "could not inspect untracked file {}: {e}",
8795 path.display()
8796 ))
8797 }
8798 };
8799 let kind = if metadata.file_type().is_symlink() {
8800 2
8801 } else if metadata.is_file() {
8802 1
8803 } else {
8804 bail!(
8805 "untracked path {} is not a regular file or symlink",
8806 path.display()
8807 );
8808 };
8809 let symlink_target = if kind == 2 {
8810 match std::fs::read_link(path) {
8811 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8812 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8813 return Ok(vanished_untracked_file())
8814 }
8815 Err(e) => {
8816 return Err(spar_err!(
8817 "could not read untracked symlink {}: {e}",
8818 path.display()
8819 ))
8820 }
8821 }
8822 } else {
8823 None
8824 };
8825 #[cfg(unix)]
8826 {
8827 use std::os::unix::fs::MetadataExt;
8828 Ok(UntrackedFile {
8829 kind,
8830 len: metadata.len(),
8831 modified: metadata.modified().ok(),
8832 created: metadata.created().ok(),
8833 readonly: metadata.permissions().readonly(),
8834 symlink_target,
8835 device: metadata.dev(),
8836 inode: metadata.ino(),
8837 mode: metadata.mode(),
8838 change_seconds: metadata.ctime(),
8839 change_nanoseconds: metadata.ctime_nsec(),
8840 })
8841 }
8842 #[cfg(not(unix))]
8843 {
8844 Ok(UntrackedFile {
8845 kind,
8846 len: metadata.len(),
8847 modified: metadata.modified().ok(),
8848 created: metadata.created().ok(),
8849 readonly: metadata.permissions().readonly(),
8850 symlink_target,
8851 })
8852 }
8853}
8854
8855#[cfg(unix)]
8856fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8857 use std::os::unix::ffi::OsStrExt;
8858 Ok(value.as_bytes().to_vec())
8859}
8860
8861#[cfg(not(unix))]
8862fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8863 value
8864 .to_str()
8865 .map(|value| value.as_bytes().to_vec())
8866 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8867}
8868
8869#[cfg(unix)]
8870fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8871 use std::os::unix::fs::MetadataExt;
8872 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8873}
8874
8875#[cfg(not(unix))]
8876fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8877 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8878}
8879
8880#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8881pub struct BranchRecord {
8882 pub kind: String,
8883 pub number: i64,
8884}
8885
8886pub fn review_ref(number: i64) -> String {
8889 format!("refs/spar/pr-{number}")
8890}
8891
8892pub fn is_finished(state: &str) -> bool {
8893 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8894}
8895
8896pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8903 if let Some(parent) = path.parent() {
8904 std::fs::create_dir_all(parent)
8905 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8906 }
8907 let tmp = path.with_extension(format!(
8910 "{}.tmp",
8911 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8912 ));
8913 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8914 std::fs::rename(&tmp, path)
8915 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8916 Ok(())
8917}
8918
8919pub fn has_heading(text: &str, title: &str) -> bool {
8926 let wanted = title.trim().to_lowercase();
8927 if wanted.is_empty() {
8928 return false;
8929 }
8930 text.lines()
8931 .filter_map(|line| line.trim().strip_prefix("## "))
8932 .any(|heading| heading.trim().to_lowercase() == wanted)
8933}
8934
8935pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8938 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8939}
8940
8941#[derive(Deserialize)]
8943#[serde(rename_all = "camelCase")]
8944pub struct LinkedPrRow {
8945 number: i64,
8946 #[serde(default)]
8947 url: String,
8948 #[serde(default)]
8949 title: String,
8950 #[serde(default)]
8951 closing_issues_references: Vec<IssueRef>,
8952}
8953
8954pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8961 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8962}
8963
8964pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8966 rows.iter()
8967 .find(|row| {
8968 row.closing_issues_references
8969 .iter()
8970 .any(|linked| linked.number == issue)
8971 })
8972 .map(|row| PrRef {
8973 number: row.number,
8974 url: row.url.clone(),
8975 title: row.title.clone(),
8976 })
8977}
8978
8979pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8981 pick_linked_pr(&linked_pr_rows(json)?, issue)
8982}
8983
8984pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8991 if text.trim().is_empty() {
8992 return Err(spar_err!("GitHub returned no comment data"));
8993 }
8994 let mut out = Vec::new();
8995 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8996 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8997 Value::Array(items) => out.extend(items),
8998 _ => return Err(spar_err!("unexpected non-array comment page")),
8999 }
9000 }
9001 Ok(out)
9002}
9003
9004pub fn parse_comment_pages(text: &str) -> Vec<Value> {
9005 let mut out = Vec::new();
9006 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9007 match value {
9008 Ok(Value::Array(items)) => out.extend(items),
9009 Ok(other) => out.push(other),
9010 Err(_) => break,
9011 }
9012 }
9013 out
9014}
9015
9016pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
9024 body.trim_start().starts_with(STATE_MARKER)
9025 && !viewer.trim().is_empty()
9026 && author.trim().eq_ignore_ascii_case(viewer.trim())
9027 && parse_state_comment(body).is_some()
9028}
9029
9030pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
9033 let marker = body.find(STATE_MARKER)?;
9034 let start = body[marker..].find('{')? + marker;
9035 let end = body.rfind('}')?;
9036 if end <= start {
9037 return None;
9038 }
9039 match serde_json::from_str(&body[start..=end]) {
9040 Ok(state) => Some(state),
9041 Err(_) => {
9042 logdim!("found a spar state comment but could not parse it");
9043 None
9044 }
9045 }
9046}
9047
9048fn choose_state_for_head(
9049 candidates: Vec<PersistedState>,
9050 actual_head: &str,
9051) -> Option<PersistedState> {
9052 let matching: Vec<PersistedState> = candidates
9053 .iter()
9054 .filter(|state| state.pr_head == actual_head)
9055 .cloned()
9056 .collect();
9057 if !matching.is_empty() {
9058 return newest_state(matching);
9059 }
9060 newest_state(candidates)
9061}
9062
9063fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
9064 candidates.into_iter().reduce(|best, candidate| {
9065 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
9066 candidate
9067 } else {
9068 best
9073 }
9074 })
9075}
9076
9077pub fn self_binary() -> Result<PathBuf> {
9083 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
9084 let path = PathBuf::from(path);
9085 if proc::is_executable(&path) {
9086 return Ok(path);
9087 }
9088 bail!(
9089 "SPAR_SELF_BIN is set to {}, which is not executable",
9090 path.display()
9091 );
9092 }
9093 std::env::current_exe()
9094 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
9095}
9096
9097fn bool_env(value: bool) -> &'static str {
9098 if value {
9099 "1"
9100 } else {
9101 "0"
9102 }
9103}
9104
9105pub fn sh_quote(text: &str) -> String {
9108 format!("'{}'", text.replace('\'', r"'\''"))
9109}
9110
9111pub fn style_from_env() -> Style {
9114 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
9115 Style {
9116 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
9117 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
9118 ..Style::permissive()
9119 }
9120}
9121
9122#[cfg(test)]
9123mod tests {
9124 use super::*;
9125 use crate::config::StateStore;
9126 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
9127 use std::process::Command;
9128
9129 fn repo_for_titles() -> Repo {
9130 Repo {
9131 root: PathBuf::from("/nonexistent"),
9132 style: Style::default(),
9133 branch_prefix: String::new(),
9134 state_store: StateStore::Local,
9135 followups: crate::config::Followups::Issues,
9136 drafts: Drafts::Never,
9137 fork_when_denied: true,
9138 setup: CheckCommand::default(),
9139 isolate_reads: true,
9140 keep_worktrees: false,
9141 fork: OnceLock::new(),
9142 viewer: OnceLock::new(),
9143 checkpoints: Mutex::new(BTreeMap::new()),
9144 writes: WriteStats::default(),
9145 }
9146 }
9147
9148 #[test]
9151 fn only_a_read_checkout_name_names_its_owner() {
9152 assert_eq!(Some(4242), read_checkout_owner("read-4242-0"));
9153 assert_eq!(None, read_checkout_owner("read-4242"));
9154 assert_eq!(None, read_checkout_owner("read-me-0"));
9155 assert_eq!(None, read_checkout_owner("read-4242-x"));
9156 assert_eq!(None, read_checkout_owner("review-4242"));
9157 }
9158
9159 #[test]
9160 fn write_results_accumulate_for_the_run() {
9161 let repo = repo_for_titles();
9162
9163 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
9164 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
9165
9166 assert_eq!(
9167 WriteSummary {
9168 attempted: 2,
9169 failed: 1,
9170 },
9171 repo.write_summary()
9172 );
9173 }
9174
9175 #[test]
9176 fn only_failed_write_preflights_join_the_summary() {
9177 let repo = repo_for_titles();
9178
9179 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
9180 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
9181
9182 assert_eq!(
9183 WriteSummary {
9184 attempted: 1,
9185 failed: 1,
9186 },
9187 repo.write_summary()
9188 );
9189 }
9190
9191 #[test]
9192 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
9193 let repo = repo_for_titles();
9194
9195 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
9196 assert_eq!(
9197 WriteSummary {
9198 attempted: 1,
9199 failed: 1,
9200 },
9201 repo.write_summary()
9202 );
9203 }
9204
9205 #[test]
9206 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
9207 let mut repo = repo_for_titles();
9208 repo.followups = Followups::Local;
9209
9210 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
9211 assert_eq!(WriteSummary::default(), repo.write_summary());
9212 }
9213
9214 #[test]
9215 fn a_failed_remote_state_read_stops_before_state_mutation() {
9216 let root = std::env::temp_dir().join(format!(
9217 "spar-state-preflight-{}-{}",
9218 std::process::id(),
9219 std::time::SystemTime::now()
9220 .duration_since(std::time::UNIX_EPOCH)
9221 .unwrap()
9222 .as_nanos()
9223 ));
9224 std::fs::create_dir_all(&root).unwrap();
9225 let _fixture = ReviewFixture { root: root.clone() };
9226 let mut repo = repo_for_titles();
9227 repo.root = root;
9228 repo.state_store = StateStore::Both;
9229 let state = PersistedState {
9230 version: 1,
9231 checkpoint: 4,
9232 round: 2,
9233 next_actor: "a".into(),
9234 status: Status::Pending,
9235 pr_head: "abc123".into(),
9236 ledger: Ledger::new(),
9237 filed: Vec::new(),
9238 open_findings: Vec::new(),
9239 disputes: Vec::new(),
9240 noted: Vec::new(),
9241 };
9242
9243 let error = repo
9244 .write_state_after_remote_read(
9245 7,
9246 &state,
9247 Err(crate::error::SparError::new("state comments unavailable")),
9248 )
9249 .unwrap_err();
9250
9251 assert!(error.to_string().contains("state comments unavailable"));
9252 assert!(!repo.state_path(7).exists());
9253 assert_eq!(0, repo.remembered_checkpoint(7));
9254 assert_eq!(
9255 WriteSummary {
9256 attempted: 1,
9257 failed: 1,
9258 },
9259 repo.write_summary()
9260 );
9261 }
9262
9263 #[test]
9264 fn only_known_build_and_cache_directories_are_generated_artifacts() {
9265 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9266 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9267 assert!(is_generated_artifact(Path::new(
9268 "package/node_modules/dependency/file.js"
9269 )));
9270 assert!(!is_generated_artifact(Path::new(
9271 "distribution/required-package.js"
9272 )));
9273 assert!(!is_generated_artifact(Path::new(
9274 "generated/required-fixture.txt"
9275 )));
9276 assert!(!is_generated_artifact(Path::new("local.env")));
9277 }
9278
9279 #[test]
9280 fn only_finder_state_files_are_desktop_metadata() {
9281 assert!(is_desktop_metadata(Path::new(".DS_Store")));
9282 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9283 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9284 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9285 }
9286
9287 struct ReviewFixture {
9288 root: PathBuf,
9289 }
9290
9291 impl Drop for ReviewFixture {
9292 fn drop(&mut self) {
9293 let _ = std::fs::remove_dir_all(&self.root);
9294 }
9295 }
9296
9297 fn test_git(cwd: &Path, args: &[&str]) -> String {
9298 let output = Command::new("git")
9299 .args(args)
9300 .current_dir(cwd)
9301 .output()
9302 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9303 assert!(
9304 output.status.success(),
9305 "git {args:?} failed: {}",
9306 String::from_utf8_lossy(&output.stderr)
9307 );
9308 String::from_utf8_lossy(&output.stdout).into_owned()
9309 }
9310
9311 #[test]
9315 fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9316 let root =
9317 std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9318 let _cleanup = ReviewFixture { root: root.clone() };
9319 let origin = root.join("origin.git");
9320 let work = root.join("work");
9321 let marker = root.join("textconv-ran");
9322 std::fs::create_dir_all(&origin).unwrap();
9323 std::fs::create_dir_all(&work).unwrap();
9324 test_git(&origin, &["init", "--bare", "-b", "main"]);
9325 test_git(&work, &["init", "-b", "main"]);
9326 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9327 test_git(&work, &["config", "user.name", "spar test"]);
9328 test_git(&work, &["config", "commit.gpgsign", "false"]);
9329 test_git(&work, &["config", "diff.noprefix", "true"]);
9330 test_git(&work, &["config", "diff.context", "0"]);
9331 test_git(&work, &["config", "diff.interHunkContext", "10"]);
9332 test_git(&work, &["config", "diff.algorithm", "patience"]);
9333 test_git(&work, &["config", "color.diff", "always"]);
9334 test_git(
9335 &work,
9336 &[
9337 "config",
9338 "diff.conv.textconv",
9339 &format!("touch {}; cat", marker.display()),
9340 ],
9341 );
9342 std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9343 let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9344 std::fs::write(work.join("src.txt"), &lines).unwrap();
9345 test_git(&work, &["add", "."]);
9346 test_git(&work, &["commit", "-m", "seed"]);
9347 test_git(
9348 &work,
9349 &["remote", "add", "origin", origin.to_str().unwrap()],
9350 );
9351 test_git(&work, &["push", "-u", "origin", "main"]);
9352 test_git(&work, &["checkout", "-b", "feature"]);
9353 std::fs::write(
9354 work.join("src.txt"),
9355 lines
9356 .replace("line 5\n", "changed 5\n")
9357 .replace("line 15\n", "changed 15\n"),
9358 )
9359 .unwrap();
9360 std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9361 std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9362 test_git(&work, &["add", "."]);
9363 test_git(&work, &["commit", "-m", "change"]);
9364 let cfg = crate::config::parse(
9365 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9366 )
9367 .unwrap();
9368 let repo = Repo::open(&work, &cfg).unwrap();
9369 assert_eq!(
9370 Hunks::from([
9371 ("new.txt".to_string(), vec![(1, 2)]),
9372 ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9373 ("with space.txt".to_string(), vec![(1, 1)]),
9374 ]),
9375 repo.diff_hunks(&work, "main")
9376 );
9377 assert!(!marker.exists(), "the text converter ran");
9378 test_git(&work, &["diff", "origin/main...HEAD"]);
9380 assert!(marker.exists(), "the fixture's converter never runs at all");
9381 }
9382
9383 fn review_fixture(
9384 tag: &str,
9385 number: i64,
9386 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9387 use std::sync::atomic::{AtomicU32, Ordering};
9388 static NEXT: AtomicU32 = AtomicU32::new(0);
9389 let id = NEXT.fetch_add(1, Ordering::Relaxed);
9390 let root =
9391 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9392 let origin = root.join("origin.git");
9393 let work = root.join("work");
9394 std::fs::create_dir_all(&origin).unwrap();
9395 std::fs::create_dir_all(&work).unwrap();
9396 test_git(&origin, &["init", "--bare", "-b", "main"]);
9397 test_git(&work, &["init", "-b", "main"]);
9398 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9399 test_git(&work, &["config", "user.name", "spar test"]);
9400 test_git(&work, &["config", "commit.gpgsign", "false"]);
9401 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9402 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9403 std::fs::write(work.join("README.md"), "seed\n").unwrap();
9404 std::fs::write(work.join("data.txt"), "old\n").unwrap();
9405 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9406 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9407 test_git(&work, &["add", "."]);
9408 test_git(&work, &["commit", "-m", "seed"]);
9409 test_git(
9410 &work,
9411 &["remote", "add", "origin", origin.to_str().unwrap()],
9412 );
9413 test_git(&work, &["push", "-u", "origin", "main"]);
9414 test_git(
9415 &work,
9416 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9417 );
9418 let cfg = crate::config::parse(
9419 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9420 )
9421 .unwrap();
9422 let repo = Repo::open(&work, &cfg).unwrap();
9423 let path = repo.worktree_for_pr_head(number).unwrap();
9424 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9425 (ReviewFixture { root }, repo, path, checkpoint)
9426 }
9427
9428 #[test]
9429 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9430 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9431
9432 repo.release_review_worktree_checked(901, &checkpoint)
9433 .unwrap();
9434
9435 assert!(!path.exists());
9436 }
9437
9438 #[test]
9439 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9440 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9441 let work = fixture.root.join("work");
9442 let seed = test_git(&work, &["rev-parse", "HEAD"]);
9443 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9444 let publish = |message: &str| {
9447 let oid = test_git(
9448 &work,
9449 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9450 );
9451 let oid = oid.trim().to_string();
9452 test_git(
9453 &work,
9454 &[
9455 "push",
9456 "--force",
9457 "origin",
9458 &format!("{oid}:refs/pull/923/head"),
9459 ],
9460 );
9461 oid
9462 };
9463 let reviewed = publish("reviewed head");
9464 repo.worktree_for_pr_head(923).unwrap();
9465 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9466
9467 let rewritten = publish("rewritten head");
9468 repo.worktree_for_pr_head(923).unwrap();
9469
9470 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9471 }
9472
9473 #[test]
9474 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9475 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9476 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9477 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9478 test_git(&path, &["add", "recovery.txt"]);
9479 test_git(&path, &["commit", "-m", "recovery commit"]);
9480 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9481 test_git(&path, &["reset", "--hard", "main"]);
9482
9483 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9484 test_git(
9485 &path,
9486 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9487 );
9488 }
9489
9490 #[test]
9491 fn a_review_ref_reflog_only_commit_prevents_deletion() {
9492 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9493 let local_ref = review_ref(921);
9494 let original = test_git(&path, &["rev-parse", &local_ref]);
9495 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9496 let recovery = test_git(
9497 &path,
9498 &[
9499 "commit-tree",
9500 tree.trim(),
9501 "-p",
9502 original.trim(),
9503 "-m",
9504 "review ref recovery",
9505 ],
9506 );
9507 test_git(
9508 &path,
9509 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9510 );
9511 test_git(
9512 &path,
9513 &["update-ref", &local_ref, original.trim(), recovery.trim()],
9514 );
9515
9516 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9517 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9518 test_git(
9519 &path,
9520 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9521 );
9522 }
9523
9524 #[test]
9525 fn an_unpublished_commit_message_draft_is_recoverable() {
9526 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9527 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9528 let git_dir = if raw.is_absolute() {
9529 raw
9530 } else {
9531 path.join(raw)
9532 };
9533 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9534
9535 assert!(repository_has_recoverable_work(&path, true).unwrap());
9536 assert_eq!(
9537 "unique recovery draft\n",
9538 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9539 );
9540 }
9541
9542 #[test]
9543 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9544 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9545 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9546
9547 let error = repo
9548 .release_review_worktree_checked(902, &checkpoint)
9549 .unwrap_err();
9550
9551 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9552 assert!(error.to_string().contains("kept for recovery"), "{error}");
9553 assert_eq!(
9554 "recover me\n",
9555 std::fs::read_to_string(path.join("README.md")).unwrap()
9556 );
9557 repo.release_review_worktree(902);
9558 }
9559
9560 #[test]
9561 fn a_review_commit_is_retained_after_a_checked_read() {
9562 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9563 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9564 test_git(&path, &["add", "review-note.txt"]);
9565 test_git(&path, &["commit", "-m", "local review recovery"]);
9566 let head = test_git(&path, &["rev-parse", "HEAD"]);
9567
9568 let error = repo
9569 .release_review_worktree_checked(903, &checkpoint)
9570 .unwrap_err();
9571
9572 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9573 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9574 assert_eq!(
9575 "recover me\n",
9576 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9577 );
9578 repo.release_review_worktree(903);
9579 }
9580
9581 #[test]
9585 fn an_ignored_review_file_is_retained_after_a_checked_read() {
9586 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9587 std::fs::create_dir_all(path.join("generated")).unwrap();
9588 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9589
9590 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9591 .expect("its own output is not a change");
9592 let error = repo
9593 .release_review_worktree_checked(904, &checkpoint)
9594 .unwrap_err();
9595
9596 assert!(error.to_string().contains("recoverable"), "{error}");
9597 assert_eq!(
9598 "recover me\n",
9599 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9600 );
9601 repo.release_review_worktree(904);
9602 }
9603
9604 #[test]
9605 fn a_preexisting_ignored_review_file_change_is_retained() {
9611 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9612 std::fs::create_dir_all(path.join("generated")).unwrap();
9613 let ignored = path.join("generated/recovery.txt");
9614 std::fs::write(&ignored, "before\n").unwrap();
9615 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9616 std::fs::write(&ignored, "after!\n").unwrap();
9617
9618 let error = repo
9619 .release_review_worktree_checked(905, &checkpoint)
9620 .unwrap_err();
9621
9622 assert!(error.to_string().contains("recoverable"), "{error}");
9623 assert!(path.exists());
9624 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9625 repo.release_review_worktree(905);
9626 }
9627
9628 #[test]
9629 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9630 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9631 std::fs::create_dir_all(path.join("generated")).unwrap();
9632 let ignored = path.join("generated/recovery.txt");
9633 std::fs::write(&ignored, "keep me\n").unwrap();
9634 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9635
9636 let error = repo
9637 .release_review_worktree_checked(906, &checkpoint)
9638 .unwrap_err();
9639
9640 assert!(error.to_string().contains("recoverable"), "{error}");
9641 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9642 }
9643
9644 #[test]
9645 fn overwriting_a_preexisting_untracked_file_is_detected() {
9646 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9647 let untracked = path.join("notes.txt");
9648 std::fs::write(&untracked, "before\n").unwrap();
9649 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9650 std::fs::write(&untracked, "after!\n").unwrap();
9651
9652 let error = repo
9653 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9654 .unwrap_err();
9655
9656 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9657 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9658 }
9659
9660 #[test]
9661 fn an_assume_unchanged_edit_is_detected() {
9662 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9663 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9664 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9665
9666 let error = repo
9667 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9668 .unwrap_err();
9669
9670 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9671 assert_eq!(
9672 "hidden\n",
9673 std::fs::read_to_string(path.join("README.md")).unwrap()
9674 );
9675 }
9676
9677 #[test]
9678 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9679 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9680 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9681 test_git(&path, &["add", "README.md"]);
9682 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9683
9684 let error = repo
9685 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9686 .unwrap_err();
9687
9688 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9689 assert_eq!(
9690 b"seed\r\n",
9691 std::fs::read(path.join("README.md")).unwrap().as_slice()
9692 );
9693 }
9694
9695 #[cfg(unix)]
9696 #[test]
9697 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9698 use std::os::unix::fs::PermissionsExt;
9699
9700 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9701 test_git(&path, &["config", "core.filemode", "false"]);
9702 let readme = path.join("README.md");
9703 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9704 permissions.set_mode(0o755);
9705 std::fs::set_permissions(&readme, permissions).unwrap();
9706 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9707
9708 let error = repo
9709 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9710 .unwrap_err();
9711
9712 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9713 assert_eq!(
9714 0o755,
9715 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9716 );
9717 }
9718
9719 #[test]
9720 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9721 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9722 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9723 test_git(&path, &["add", ".gitattributes"]);
9724 test_git(&path, &["commit", "-m", "select data filter"]);
9725 let baseline = repo.worktree_baseline(&path).unwrap();
9726 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9727
9728 assert!(repo
9729 .commit_pending_changes(&path, &baseline, "change data", "change data")
9730 .unwrap());
9731 let error = repo
9732 .refuse_unrepresented_tracked_changes(&path, &baseline)
9733 .unwrap_err();
9734
9735 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9736 assert_eq!(
9737 "secret: recover me\nnew\n",
9738 std::fs::read_to_string(path.join("data.txt")).unwrap()
9739 );
9740 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9741 }
9742
9743 #[test]
9744 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9745 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9746 std::fs::create_dir_all(path.join("target")).unwrap();
9747 let untracked = path.join("target/user.yaml");
9748 std::fs::write(&untracked, "user data\n").unwrap();
9749 let baseline = repo.worktree_baseline(&path).unwrap();
9750 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9751
9752 assert!(repo
9753 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9754 .unwrap());
9755
9756 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9757 assert_eq!(
9758 "?? target/user.yaml\n",
9759 test_git(&path, &["status", "--short", "--untracked-files=all"])
9760 );
9761 assert!(test_git(
9762 &path,
9763 &[
9764 "ls-tree",
9765 "-r",
9766 "--name-only",
9767 "HEAD",
9768 "--",
9769 "target/user.yaml"
9770 ]
9771 )
9772 .is_empty());
9773 }
9774
9775 #[test]
9776 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9777 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9778 std::fs::create_dir_all(path.join("target")).unwrap();
9779 let untracked = path.join("target/user.yaml");
9780 std::fs::write(&untracked, "before\n").unwrap();
9781 let baseline = repo.worktree_baseline(&path).unwrap();
9782 let before = test_git(&path, &["rev-parse", "HEAD"]);
9783 std::fs::write(&untracked, "after\n").unwrap();
9784 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9785
9786 let error = repo
9787 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9788 .unwrap_err();
9789
9790 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9791 assert!(error.to_string().contains("target/user.yaml"), "{error}");
9792 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9793 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9794 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9795 }
9796
9797 #[test]
9798 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9799 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9800 let baseline = repo.worktree_baseline(&path).unwrap();
9801 std::fs::create_dir_all(path.join("target")).unwrap();
9802 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9803
9804 assert!(repo
9805 .commit_pending_changes(&path, &baseline, "add file", "add file")
9806 .unwrap());
9807
9808 assert_eq!(
9809 "new file\n",
9810 test_git(&path, &["show", "HEAD:target/new.txt"])
9811 );
9812 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9813 }
9814
9815 #[test]
9816 fn a_managed_commit_accepts_a_custom_output_rebuild() {
9817 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9818 exclude_paths(&repo, &["manager/public/"]);
9819 let output = path.join("manager/public/assets");
9820 std::fs::create_dir_all(&output).unwrap();
9821 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9822 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9823 let baseline = repo.worktree_baseline(&path).unwrap();
9824 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9825 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9826 std::fs::remove_file(output.join("old.js")).unwrap();
9827 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9828 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9829
9830 assert!(repo
9831 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9832 .unwrap());
9833 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9834 repo.refuse_changed_existing_untracked(&path, &baseline)
9835 .unwrap();
9836 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9837 .unwrap();
9838
9839 assert_eq!(
9840 "tracked change\n",
9841 test_git(&path, &["show", "HEAD:README.md"])
9842 );
9843 assert_eq!(
9844 "new source\n",
9845 test_git(&path, &["show", "HEAD:new-source.txt"])
9846 );
9847 assert!(test_git(
9848 &path,
9849 &[
9850 "ls-tree",
9851 "-r",
9852 "--name-only",
9853 "HEAD",
9854 "--",
9855 "manager/public"
9856 ]
9857 )
9858 .is_empty());
9859 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9860 assert!(!output.join("old.js").exists());
9861 assert_eq!(
9862 "new styles\n",
9863 std::fs::read_to_string(output.join("index.css")).unwrap()
9864 );
9865 assert!(repository_has_recoverable_work(&path, true).unwrap());
9866 repo.release_review_worktree(912);
9867 assert!(output.join("index-DUym-Rfj.js").exists());
9868 }
9869
9870 #[test]
9871 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9872 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9873 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9874 let baseline = repo.worktree_baseline(&path).unwrap();
9875 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9876 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9877
9878 let error = repo
9879 .commit_pending_changes(&path, &baseline, "change", "change")
9880 .unwrap_err();
9881
9882 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9883 assert!(error.to_string().contains("notes.txt"), "{error}");
9884 }
9885
9886 #[test]
9887 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9888 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9889 let baseline = repo.worktree_baseline(&path).unwrap();
9890 let nested = path.join("generated/repro");
9891 std::fs::create_dir_all(&nested).unwrap();
9892 test_git(&nested, &["init"]);
9893 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9894
9895 assert!(repo
9896 .commit_pending_changes(&path, &baseline, "change", "change")
9897 .unwrap());
9898 assert!(!repo.has_uncommitted_changes(&path).unwrap());
9899 assert!(repository_has_recoverable_work(&path, true).unwrap());
9900 repo.release_review_worktree(927);
9901 assert!(nested.join(".git").exists());
9902 }
9903
9904 #[test]
9905 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9906 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9907 test_git(&path, &["config", "core.autocrlf", "true"]);
9908 std::fs::write(path.join("README.md"), "seed\n").unwrap();
9909 assert_eq!(
9910 test_git(&path, &["hash-object", "README.md"]).trim(),
9911 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9912 );
9913
9914 assert!(repository_has_recoverable_work(&path, true).unwrap());
9915 assert_eq!(
9916 "seed\n",
9917 std::fs::read_to_string(path.join("README.md")).unwrap()
9918 );
9919 }
9920
9921 #[test]
9922 fn autocrlf_input_overrides_a_crlf_core_eol() {
9923 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9924 test_git(&path, &["config", "core.autocrlf", "input"]);
9925 test_git(&path, &["config", "core.eol", "crlf"]);
9926 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9927 assert_eq!(
9928 test_git(&path, &["hash-object", "README.md"]).trim(),
9929 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9930 );
9931
9932 assert!(repository_has_recoverable_work(&path, true).unwrap());
9933 assert_eq!(
9934 b"seed\r\n",
9935 std::fs::read(path.join("README.md")).unwrap().as_slice()
9936 );
9937 }
9938
9939 #[cfg(unix)]
9940 #[test]
9941 fn a_non_executable_permission_change_is_recoverable() {
9942 use std::os::unix::fs::PermissionsExt;
9943
9944 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9945 let readme = path.join("README.md");
9946 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9947 permissions.set_mode(0o600);
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!(repository_has_recoverable_work(&path, true).unwrap());
9957 assert_eq!(
9958 0o600,
9959 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9960 );
9961 }
9962
9963 #[cfg(unix)]
9964 #[test]
9965 fn a_managed_commit_skips_signing_and_hooks() {
9966 use std::os::unix::fs::PermissionsExt;
9967
9968 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9969 let common = common_git_dir(&path).unwrap();
9970 let hook = common.join("hooks/pre-commit");
9971 let marker = fixture.root.join("hook-ran");
9972 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9973 std::fs::write(
9974 &hook,
9975 format!(
9976 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9977 sh_quote(marker.to_str().unwrap())
9978 ),
9979 )
9980 .unwrap();
9981 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9982 permissions.set_mode(0o755);
9983 std::fs::set_permissions(&hook, permissions).unwrap();
9984 test_git(&path, &["config", "commit.gpgsign", "true"]);
9985 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9986 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9987 test_git(&path, &["add", "managed.txt"]);
9988
9989 repo.commit_staged_changes(&path, "record managed change")
9990 .unwrap();
9991
9992 assert!(!marker.exists());
9993 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9994 }
9995
9996 #[test]
9997 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9998 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9999 std::fs::write(
10000 path.join(".gitattributes"),
10001 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
10002 )
10003 .unwrap();
10004 test_git(&path, &["add", ".gitattributes"]);
10005 test_git(&path, &["commit", "-m", "select automatic text"]);
10006 test_git(&path, &["config", "core.autocrlf", "true"]);
10007
10008 assert!(repository_has_recoverable_work(&path, true).unwrap());
10009 }
10010
10011 #[test]
10012 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
10013 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
10014 std::fs::write(
10015 path.join(".gitattributes"),
10016 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
10017 )
10018 .unwrap();
10019 test_git(&path, &["add", ".gitattributes"]);
10020 test_git(&path, &["commit", "-m", "select ident expansion"]);
10021 std::fs::write(path.join("README.md"), "seed\n").unwrap();
10022
10023 assert!(repository_has_recoverable_work(&path, true).unwrap());
10024 }
10025
10026 #[test]
10032 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
10033 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
10034 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
10035 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10036 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
10037 std::fs::write(
10038 path.join("bindings/uniffi-bindgen/Cargo.lock"),
10039 "# generated\n",
10040 )
10041 .unwrap();
10042
10043 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10044 .expect("a path the project ignores is the call's own output");
10045 }
10046
10047 #[test]
10048 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
10049 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
10050 exclude_paths(&repo, &["target/"]);
10051 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10052 std::fs::create_dir_all(path.join("target/package")).unwrap();
10053 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
10054
10055 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10056 .expect("an ignored attribute file governs only ignored paths");
10057 assert!(
10058 !repo.has_uncommitted_changes(&path).unwrap(),
10059 "ignored dependency attributes must not stop the next review"
10060 );
10061 }
10062
10063 #[test]
10064 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
10065 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
10066 std::fs::create_dir_all(path.join("source")).unwrap();
10067 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
10068
10069 assert!(repo.has_uncommitted_changes(&path).unwrap());
10070 }
10071
10072 #[test]
10073 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
10074 for source in ["repository", "nested", "local", "global"] {
10075 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
10076 std::fs::create_dir_all(path.join("manager")).unwrap();
10077 match source {
10078 "repository" => {
10079 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
10080 .unwrap();
10081 test_git(&path, &["add", ".gitignore"]);
10082 test_git(&path, &["commit", "-m", "ignore artifacts"]);
10083 }
10084 "nested" => {
10085 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
10086 test_git(&path, &["add", "manager/.gitignore"]);
10087 test_git(&path, &["commit", "-m", "ignore artifacts"]);
10088 }
10089 "local" => exclude_paths(&repo, &["manager/scratch/"]),
10090 "global" => {
10091 let excludes = repo.root().parent().unwrap().join("global-ignore");
10092 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
10093 test_git(
10094 &path,
10095 &["config", "core.excludesFile", excludes.to_str().unwrap()],
10096 );
10097 }
10098 _ => unreachable!(),
10099 }
10100 let output = path.join("manager/scratch");
10101 std::fs::create_dir_all(&output).unwrap();
10102 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
10103 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
10104 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10105 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
10106 std::fs::remove_file(output.join(".DS_Store")).unwrap();
10107 for file in [
10108 ".gitattributes",
10109 "Cargo.lock",
10110 "font.woff2",
10111 "bundle.js",
10112 "cache.db",
10113 ] {
10114 std::fs::write(output.join(file), "generated\n").unwrap();
10115 }
10116 #[cfg(unix)]
10117 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
10118 let nested = output.join("dependency");
10119 std::fs::create_dir_all(&nested).unwrap();
10120 test_git(&nested, &["init"]);
10121 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10122 .unwrap_or_else(|error| panic!("{source}: {error}"));
10123 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10124
10125 let baseline = repo.worktree_baseline(&path).unwrap();
10126 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
10127 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
10128 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
10129 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
10130 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
10131 assert!(repo
10132 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
10133 .unwrap_or_else(|error| panic!("{source}: {error}")));
10134 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10135 assert!(test_git(
10136 &path,
10137 &[
10138 "ls-tree",
10139 "-r",
10140 "--name-only",
10141 "HEAD",
10142 "--",
10143 "manager/scratch"
10144 ]
10145 )
10146 .is_empty());
10147 assert!(
10148 repository_has_recoverable_work(&path, true).unwrap(),
10149 "{source}"
10150 );
10151 repo.release_review_worktree(945);
10152 assert_eq!(
10153 "LOCAL=updated\n",
10154 std::fs::read_to_string(output.join(".env.local")).unwrap()
10155 );
10156 assert!(nested.join(".git").exists(), "{source}");
10157 }
10158 }
10159
10160 #[test]
10164 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
10165 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
10166 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10167 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
10168
10169 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10170 .expect("spar's own marker is not something a call did");
10171 }
10172
10173 #[test]
10174 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
10175 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
10176
10177 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
10178 .expect("a path a build removed is not an unreadable worktree");
10179
10180 assert_eq!(vanished_untracked_file(), gone);
10181 }
10182
10183 #[test]
10186 fn a_vanished_path_is_not_recorded_as_absent() {
10187 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
10188 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10189 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10190 std::fs::remove_file(path.join("notes.txt")).unwrap();
10191
10192 let error = repo
10193 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10194 .unwrap_err();
10195
10196 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10197 }
10198
10199 fn exclude_paths(repo: &Repo, lines: &[&str]) {
10200 use std::io::Write;
10201 let path = repo.root().join(".git").join("info").join("exclude");
10202 let mut file = std::fs::OpenOptions::new()
10203 .create(true)
10204 .append(true)
10205 .open(&path)
10206 .unwrap();
10207 for line in lines {
10208 writeln!(file, "{line}").unwrap();
10209 }
10210 }
10211
10212 #[test]
10213 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
10214 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
10215 exclude_paths(&repo, &["dist/"]);
10216 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10217 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10218 let baseline = repo.worktree_baseline(&path).unwrap();
10219 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10220 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10221 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10222 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10223
10224 assert!(repo
10225 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10226 .unwrap());
10227 assert_eq!(
10228 "tracked change\n",
10229 std::fs::read_to_string(path.join("README.md")).unwrap()
10230 );
10231 }
10232
10233 #[test]
10234 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10235 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10236 exclude_paths(&repo, &["dist/"]);
10237 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10238 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10239 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10240 let baseline = repo.worktree_baseline(&path).unwrap();
10241 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10242 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10243 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10244 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10245
10246 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10247 repo.refuse_changed_existing_untracked(&path, &baseline)
10248 .unwrap();
10249 }
10250
10251 #[test]
10252 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10253 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10254 exclude_paths(&repo, &["dist/"]);
10255 std::fs::create_dir_all(path.join("dist")).unwrap();
10256 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10257 let baseline = repo.worktree_baseline(&path).unwrap();
10258 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10259 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10260
10261 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10262 repo.refuse_changed_existing_untracked(&path, &baseline)
10263 .unwrap();
10264 }
10265
10266 #[test]
10275 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10276 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10277 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10278 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10279 test_git(
10280 repo.root(),
10281 &[
10282 "worktree",
10283 "add",
10284 "-b",
10285 "issue-950",
10286 held.to_str().unwrap(),
10287 "main",
10288 ],
10289 );
10290
10291 let found = repo
10292 .worktree_holding("issue-950")
10293 .expect("the branch is checked out somewhere");
10294
10295 assert!(found.ends_with("issue-950"), "{}", found.display());
10296 assert_eq!(None, repo.worktree_holding("issue-951"));
10297 }
10298
10299 #[test]
10300 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10301 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10302 exclude_paths(&repo, &["manager/public/assets/"]);
10303 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10304 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10305 std::fs::write(&bundle, "first build\n").unwrap();
10306 let baseline = repo.worktree_baseline(&path).unwrap();
10307 std::fs::write(&bundle, "second build\n").unwrap();
10308 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10309
10310 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10311 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10312 }
10313
10314 #[test]
10315 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10316 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10317 exclude_paths(&repo, &["dist/"]);
10318 let baseline = repo.worktree_baseline(&path).unwrap();
10319 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10320
10321 let error = repo
10322 .refuse_read_only_leavings(&path, &baseline)
10323 .unwrap_err();
10324
10325 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10326 assert!(error.to_string().contains("scratch.md"), "{error}");
10327 }
10328
10329 #[test]
10330 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10331 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10332 exclude_paths(&repo, &["dist/", ".env.local"]);
10333 std::fs::create_dir_all(path.join("dist")).unwrap();
10334 let baseline = repo.worktree_baseline(&path).unwrap();
10335 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10336 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10337
10338 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10339 assert!(repository_has_recoverable_work(&path, true).unwrap());
10340 }
10341
10342 #[test]
10343 fn a_read_only_inspection_may_rebuild_generated_output() {
10344 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10345 exclude_paths(&repo, &["dist/"]);
10346 std::fs::create_dir_all(path.join("dist")).unwrap();
10347 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10348 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10349 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10350 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10351
10352 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10353 .unwrap();
10354 }
10355
10356 #[test]
10357 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10358 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10359 exclude_paths(&repo, &["dist/", ".env.local"]);
10360 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10361 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10362 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10363
10364 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10365 .unwrap();
10366 }
10367
10368 #[test]
10369 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10370 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10371 exclude_paths(&repo, &["dist/", ".env.local"]);
10372 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10373 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10374 std::fs::remove_file(path.join(".env.local")).unwrap();
10375
10376 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10377 .unwrap();
10378 }
10379
10380 #[test]
10381 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10382 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10383 exclude_paths(&repo, &["dist/"]);
10384 std::fs::write(path.join("notes.md"), "before\n").unwrap();
10385 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10386 std::fs::write(path.join("notes.md"), "after\n").unwrap();
10387
10388 let error = repo
10389 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10390 .unwrap_err();
10391
10392 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10393 assert!(error.to_string().contains("notes.md"), "{error}");
10394 }
10395
10396 #[test]
10397 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10398 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10399 exclude_paths(&repo, &["dist/", "local.env"]);
10400 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10401 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10402 std::fs::write(
10405 repo.root().join(".git").join("info").join("exclude"),
10406 "dist/\n",
10407 )
10408 .unwrap();
10409
10410 let error = repo
10411 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10412 .unwrap_err();
10413
10414 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10415 assert!(error.to_string().contains("local.env"), "{error}");
10416 }
10417
10418 #[test]
10419 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10420 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10421 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10422 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10423
10424 let error = repo
10425 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10426 .unwrap_err();
10427
10428 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10429 assert!(error.to_string().contains("README.md"), "{error}");
10430 }
10431
10432 #[test]
10433 fn build_output_alone_does_not_keep_a_worktree() {
10434 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10435 exclude_paths(&repo, &["target/", "dist/"]);
10436 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10437 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10438 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10439 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10440
10441 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10442 repo.release_review_worktree(933);
10443
10444 assert!(!path.exists());
10445 }
10446
10447 #[test]
10448 fn finder_metadata_alone_does_not_keep_a_worktree() {
10449 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10450 exclude_paths(&repo, &[".DS_Store"]);
10451 std::fs::create_dir_all(path.join("src")).unwrap();
10452 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10453 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10454
10455 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10456 repo.release_review_worktree(937);
10457
10458 assert!(!path.exists());
10459 }
10460
10461 #[test]
10462 fn a_fetch_record_alone_does_not_keep_a_worktree() {
10463 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10464 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10465 let git_dir = if raw.is_absolute() {
10466 raw
10467 } else {
10468 path.join(raw)
10469 };
10470 let head = test_git(&path, &["rev-parse", "HEAD"]);
10471 std::fs::write(
10472 git_dir.join("FETCH_HEAD"),
10473 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10474 )
10475 .unwrap();
10476
10477 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10478 repo.release_review_worktree(938);
10479
10480 assert!(!path.exists());
10481 }
10482
10483 #[test]
10484 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10485 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10486 exclude_paths(&repo, &["target/", ".env.local"]);
10487 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10488 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10489 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10490
10491 assert!(repository_has_recoverable_work(&path, true).unwrap());
10492 repo.release_review_worktree(934);
10493
10494 assert_eq!(
10495 "TOKEN=keep me\n",
10496 std::fs::read_to_string(path.join(".env.local")).unwrap()
10497 );
10498 }
10499
10500 #[test]
10501 fn a_repository_nested_in_build_output_keeps_a_worktree() {
10502 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10503 exclude_paths(&repo, &["node_modules/"]);
10504 let nested = path.join("node_modules/local-dep");
10505 std::fs::create_dir_all(&nested).unwrap();
10506 test_git(&nested, &["init"]);
10507 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10508
10509 assert!(repository_has_recoverable_work(&path, true).unwrap());
10510 repo.release_review_worktree(935);
10511
10512 assert!(nested.join(".git").exists());
10513 }
10514
10515 #[test]
10516 fn an_ordinary_untracked_file_keeps_a_worktree() {
10517 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10518 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10519
10520 assert!(repository_has_recoverable_work(&path, true).unwrap());
10521 repo.release_review_worktree(936);
10522
10523 assert_eq!(
10524 "somebody's notes\n",
10525 std::fs::read_to_string(path.join("notes.md")).unwrap()
10526 );
10527 }
10528
10529 #[test]
10530 fn a_legacy_crlf_checkout_is_retained_conservatively() {
10531 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10532 std::fs::write(
10533 path.join(".gitattributes"),
10534 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10535 )
10536 .unwrap();
10537 test_git(&path, &["add", ".gitattributes"]);
10538 test_git(&path, &["commit", "-m", "select legacy line endings"]);
10539
10540 assert!(repository_has_recoverable_work(&path, true).unwrap());
10541 }
10542
10543 #[test]
10544 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10545 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10546 let nested = path.join("tracked");
10547 std::fs::create_dir_all(&nested).unwrap();
10548 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10549 test_git(&path, &["add", "tracked/seed.txt"]);
10550 test_git(&path, &["commit", "-m", "add tracked directory"]);
10551 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10552 test_git(&nested, &["init"]);
10553
10554 let error = repo
10555 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10556 .unwrap_err();
10557
10558 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10559 assert!(error.to_string().contains("Git entry"), "{error}");
10560 assert!(nested.join(".git").exists());
10561 }
10562
10563 #[test]
10568 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10569 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10570 exclude_paths(&repo, &["target/"]);
10571 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10572 let repro = path.join("target/pr918-repro");
10573 std::fs::create_dir_all(&repro).unwrap();
10574 test_git(&repro, &["init"]);
10575 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10576
10577 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10578 .unwrap();
10579
10580 assert!(repository_has_recoverable_work(&path, true).unwrap());
10581 assert!(repro.join(".git").exists());
10582 }
10583
10584 #[test]
10588 fn a_repository_inside_a_nested_checkout_is_left_alone() {
10589 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10590 exclude_paths(&repo, &["target/"]);
10591 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10592 let repro = path.join("target/repro");
10593 let vendored = repro.join("vendor/dep");
10594 std::fs::create_dir_all(&vendored).unwrap();
10595 test_git(&repro, &["init"]);
10596 test_git(&vendored, &["init"]);
10597
10598 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10599 .unwrap();
10600
10601 assert!(vendored.join(".git").exists());
10602 }
10603
10604 #[test]
10605 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10606 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10607
10608 let state = ignored_untracked_state(repo.root()).unwrap();
10609
10610 let relative = path.strip_prefix(repo.root()).unwrap();
10611 assert!(
10612 state.files.contains_key(relative),
10613 "{:?}",
10614 state.files.keys().collect::<Vec<_>>()
10615 );
10616 assert!(state.is_ignored(relative));
10617 }
10618
10619 #[test]
10620 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10621 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10622 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10623 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10624 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10625
10626 repo.refuse_new_ignored_files(repo.root(), &baseline)
10627 .unwrap();
10628 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10629 .unwrap();
10630 }
10631
10632 #[test]
10633 fn deleting_a_resident_worktree_during_a_call_is_refused() {
10634 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10635 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10636 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10637 std::fs::remove_dir_all(&path).unwrap();
10638
10639 let read_error = repo
10640 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10641 .unwrap_err();
10642 assert!(
10643 read_error.to_string().contains("review-932"),
10644 "{read_error}"
10645 );
10646 let error = repo
10647 .refuse_new_ignored_files(repo.root(), &baseline)
10648 .unwrap_err();
10649
10650 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10651 assert!(error.to_string().contains("review-932"), "{error}");
10652 }
10653
10654 #[test]
10655 fn a_nested_repository_record_is_read_as_a_plain_path() {
10656 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10657 assert_eq!(Path::new("vendor/checkout"), path);
10658 assert!(nested);
10659
10660 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10661 assert_eq!(Path::new("vendor/notes.txt"), path);
10662 assert!(!nested);
10663
10664 assert!(untracked_record(b"/", "untracked").is_err());
10665 }
10666
10667 #[cfg(unix)]
10668 #[test]
10669 fn a_non_utf8_git_path_is_preserved_without_loss() {
10670 use std::os::unix::ffi::OsStrExt;
10671
10672 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10673
10674 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10675 }
10676
10677 #[test]
10678 fn guarded_merge_pins_the_reviewed_head() {
10679 let args = merge_pr_args("36", Some("abc123"), true);
10680 assert_eq!(
10681 vec![
10682 "pr",
10683 "merge",
10684 "36",
10685 "--squash",
10686 "--delete-branch",
10687 "--match-head-commit",
10688 "abc123"
10689 ],
10690 args
10691 );
10692 }
10693
10694 #[test]
10695 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10696 let pr = PrRef {
10697 number: 7,
10698 url: "https://example.test/pull/7".into(),
10699 title: "part one".into(),
10700 };
10701 let result = reconcile_pr_creation(
10702 "split-34-1",
10703 Err(crate::error::SparError::new("connection lost")),
10704 Ok(Some(pr)),
10705 )
10706 .unwrap();
10707 assert_eq!(7, result.number);
10708 }
10709
10710 #[test]
10711 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10712 let error = reconcile_pr_creation(
10713 "split-34-1",
10714 Err(crate::error::SparError::new("permission denied")),
10715 Ok(None),
10716 )
10717 .unwrap_err();
10718 assert!(error.to_string().contains("permission denied"), "{error}");
10719 }
10720
10721 #[test]
10722 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10723 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10724 assert!(pr_for_base(text, "split-34-2", "split-34-1")
10725 .unwrap()
10726 .is_none());
10727 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10728 assert_eq!(7, found.number);
10729 }
10730
10731 #[test]
10732 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10733 let result = reconcile_comment_post(
10734 34,
10735 "the summary",
10736 crate::error::SparError::new("connection lost"),
10737 Ok(vec![serde_json::json!({"body": "the summary"})]),
10738 );
10739 assert!(result.is_ok(), "{result:?}");
10740 }
10741
10742 #[test]
10743 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10744 let error = reconcile_comment_post(
10745 34,
10746 "the summary",
10747 crate::error::SparError::new("connection lost"),
10748 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10749 )
10750 .unwrap_err();
10751 assert_eq!("connection lost", error.to_string());
10752 }
10753
10754 #[test]
10755 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10756 let error = reconcile_comment_post(
10757 34,
10758 "the summary",
10759 crate::error::SparError::new("connection lost"),
10760 Err(crate::error::SparError::new("comments unavailable")),
10761 )
10762 .unwrap_err();
10763 assert!(
10764 error.to_string().contains("could not be verified"),
10765 "{error}"
10766 );
10767 assert!(
10768 error.to_string().contains("comments unavailable"),
10769 "{error}"
10770 );
10771 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10772 assert!(!error.worth_retrying());
10773 }
10774
10775 #[test]
10776 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10777 let result = reconcile_issue_edit(
10778 34,
10779 "wanted body",
10780 crate::error::SparError::new("connection lost"),
10781 Ok("wanted body".to_string()),
10782 );
10783 assert!(result.is_ok(), "{result:?}");
10784 }
10785
10786 #[test]
10787 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10788 let error = reconcile_issue_edit(
10789 34,
10790 "wanted body",
10791 crate::error::SparError::new("connection lost"),
10792 Err(crate::error::SparError::new("issue unavailable")),
10793 )
10794 .unwrap_err();
10795 assert!(
10796 error.to_string().contains("could not be verified"),
10797 "{error}"
10798 );
10799 assert!(error.to_string().contains("issue unavailable"), "{error}");
10800 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10801 assert!(!error.worth_retrying());
10802 }
10803
10804 #[test]
10805 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10806 let found = ExistingIssue {
10807 number: 101,
10808 url: "https://example.test/issues/101".into(),
10809 title: "child".into(),
10810 body: "body".into(),
10811 open: true,
10812 };
10813 let url = reconcile_issue_creation(
10814 "child",
10815 Err(crate::error::SparError::new("connection lost")),
10816 Ok(Some(found)),
10817 )
10818 .unwrap();
10819 assert_eq!("https://example.test/issues/101", url);
10820 }
10821
10822 #[test]
10823 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10824 let error = reconcile_issue_creation(
10825 "child",
10826 Err(crate::error::SparError::new("permission denied")),
10827 Ok(None),
10828 )
10829 .unwrap_err();
10830 assert!(error.to_string().contains("permission denied"), "{error}");
10831 }
10832
10833 #[test]
10834 fn an_unverifiable_issue_creation_is_marked_uncertain() {
10835 let error = reconcile_issue_creation(
10836 "child",
10837 Err(crate::error::SparError::new("connection lost")),
10838 Err(crate::error::SparError::new("issues unavailable")),
10839 )
10840 .unwrap_err();
10841 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10842 assert!(!error.worth_retrying());
10843 }
10844
10845 #[test]
10846 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10847 let result = reconcile_failed_split_push(
10848 "split-34-1",
10849 crate::error::SparError::new("connection lost"),
10850 Ok("abc123\n".into()),
10851 Ok("abc123\trefs/heads/split-34-1\n".into()),
10852 );
10853 assert!(result.is_ok(), "{result:?}");
10854 }
10855
10856 #[test]
10857 fn a_split_push_collision_is_definite_and_never_overwrites() {
10858 let error = reconcile_failed_split_push(
10859 "split-34-1",
10860 crate::error::SparError::new("lease rejected"),
10861 Ok("abc123\n".into()),
10862 Ok("def456\trefs/heads/split-34-1\n".into()),
10863 )
10864 .unwrap_err();
10865 assert!(!error.retain_worktree());
10866 assert!(
10867 error.to_string().contains("Nothing was overwritten"),
10868 "{error}"
10869 );
10870 }
10871
10872 #[test]
10873 fn an_unreadable_split_push_result_keeps_the_worktree() {
10874 let error = reconcile_failed_split_push(
10875 "split-34-1",
10876 crate::error::SparError::new("connection lost"),
10877 Ok("abc123\n".into()),
10878 Err(crate::error::SparError::new("origin unavailable")),
10879 )
10880 .unwrap_err();
10881 assert!(error.retain_worktree());
10882 assert!(error.to_string().contains("could not confirm"), "{error}");
10883 }
10884
10885 #[test]
10889 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10890 let repo = repo_for_titles();
10891 for raw in [
10892 "Retry loop spins \u{2014} Retry-After parses to zero",
10893 "plain title",
10894 " spread over\nlines ",
10895 "\u{1F916} Generated with something",
10896 &format!("a \u{2014} {}", "very long title ".repeat(20)),
10897 &"x".repeat(300),
10898 &format!("{} \u{2014} end", "y".repeat(88)),
10899 &{
10904 let tail = "a\u{2014}b c\u{2014}d";
10905 let pad = Style::default().max_title_chars - tail.chars().count();
10906 format!("{}{tail}", "w".repeat(pad))
10907 },
10908 ] {
10909 let once = repo.clean_title(raw).unwrap();
10910 let twice = repo.clean_title(&once).unwrap();
10911 assert_eq!(once, twice, "not idempotent for {raw:?}");
10912 assert!(
10913 once.chars().count() <= repo.style.max_title_chars,
10914 "over budget: {once:?}"
10915 );
10916 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10917 }
10918 }
10919
10920 #[test]
10921 fn a_title_with_an_em_dash_survives_as_readable_text() {
10922 let repo = repo_for_titles();
10923 assert_eq!(
10924 "Retry loop spins, Retry-After parses to zero",
10925 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10926 .unwrap()
10927 );
10928 }
10929
10930 #[test]
10931 fn sh_quote_survives_a_quote() {
10932 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10933 }
10934
10935 #[test]
10936 fn sh_quote_wraps_a_space() {
10937 assert_eq!(
10938 "'/Applications/My App/spar'",
10939 sh_quote("/Applications/My App/spar")
10940 );
10941 }
10942
10943 #[test]
10944 fn finished_states_are_recognised_case_insensitively() {
10945 assert!(is_finished("MERGED"));
10946 assert!(is_finished("closed"));
10947 assert!(!is_finished("OPEN"));
10948 assert!(!is_finished(""));
10949 }
10950
10951 fn state() -> PersistedState {
10952 PersistedState {
10953 version: 1,
10954 checkpoint: 0,
10955 round: 4,
10956 next_actor: "codex".into(),
10957 status: Status::Pending,
10958 pr_head: "abc123".into(),
10959 ledger: Ledger::new(),
10960 filed: vec![],
10961 open_findings: vec![Finding {
10962 severity: Severity::Blocking,
10963 title: "Unchecked error".into(),
10964 detail: "the failure is discarded".into(),
10965 file: "src/a.rs:12".into(),
10966 ..Finding::default()
10967 }],
10968 disputes: vec![Dispute {
10969 title: "Retry limit".into(),
10970 file: "src/net.rs".into(),
10971 reasoning: "the caller already bounds it".into(),
10972 }],
10973 noted: vec![Finding {
10974 severity: Severity::NonBlocking,
10975 title: "Timeout is fixed".into(),
10976 file: "src/config.rs".into(),
10977 ..Finding::default()
10978 }],
10979 }
10980 }
10981
10982 #[test]
10983 fn a_state_comment_round_trips() {
10984 let body = format!(
10985 "{STATE_MARKER}\n{}\n-->",
10986 serde_json::to_string(&state()).unwrap()
10987 );
10988 let back = parse_state_comment(&body).unwrap();
10989 assert_eq!(4, back.round);
10990 assert_eq!("codex", back.next_actor);
10991 assert_eq!("abc123", back.pr_head);
10992 assert_eq!("Unchecked error", back.open_findings[0].title);
10993 assert_eq!("src/net.rs", back.disputes[0].file);
10994 assert_eq!("Timeout is fixed", back.noted[0].title);
10995 }
10996
10997 #[test]
10998 fn old_state_without_new_lists_still_parses() {
10999 let body = format!(
11000 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
11001 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11002 );
11003 let back = parse_state_comment(&body).expect("old state");
11004 assert!(back.open_findings.is_empty());
11005 assert!(back.disputes.is_empty());
11006 assert!(back.noted.is_empty());
11007 assert!(back.pr_head.is_empty());
11008 assert_eq!(0, back.checkpoint);
11009 }
11010
11011 #[test]
11012 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
11013 let mut local = state();
11014 local.pr_head = "old".into();
11015 local.round = 9;
11016 let mut remote = state();
11017 remote.pr_head = "current".into();
11018 remote.round = 4;
11019
11020 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11021 assert_eq!("current", chosen.pr_head);
11022 assert_eq!(4, chosen.round);
11023 }
11024
11025 #[test]
11026 fn checkpoint_order_breaks_same_round_ties() {
11027 let mut local = state();
11028 local.pr_head = "current".into();
11029 local.round = 4;
11030 local.checkpoint = 8;
11031 let mut remote = local.clone();
11032 remote.checkpoint = 7;
11033 remote.open_findings.clear();
11034
11035 let chosen = choose_state_for_head(vec![local], "current").unwrap();
11036 assert_eq!(8, chosen.checkpoint);
11037
11038 let mut local = state();
11039 local.pr_head = "current".into();
11040 local.round = 4;
11041 local.checkpoint = 8;
11042 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
11043 assert_eq!(8, chosen.checkpoint);
11044 }
11045
11046 #[test]
11047 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
11048 let mut local = state();
11049 local.pr_head = "current".into();
11050 local.round = 4;
11051 local.open_findings.push(Finding {
11052 title: "local checkpoint".into(),
11053 ..Finding::default()
11054 });
11055 let mut remote = state();
11056 remote.pr_head = "current".into();
11057 remote.round = 4;
11058
11059 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11060 assert_eq!(
11061 "local checkpoint",
11062 chosen.open_findings.last().unwrap().title
11063 );
11064 }
11065
11066 #[test]
11068 fn the_state_block_is_an_html_comment() {
11069 let body = format!(
11070 "{STATE_MARKER}\n{}\n-->",
11071 serde_json::to_string(&state()).unwrap()
11072 );
11073 assert!(body.starts_with("<!--"));
11074 assert!(body.trim_end().ends_with("-->"));
11075 assert!(!body[..body.find('{').unwrap()].contains("-->"));
11076 }
11077
11078 #[test]
11079 fn an_unrelated_json_block_is_not_state() {
11080 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
11081 }
11082
11083 #[test]
11090 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
11091 let block = format!(
11092 "{STATE_MARKER}\n{}\n-->",
11093 serde_json::to_string(&state()).unwrap()
11094 );
11095 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
11096 assert!(
11097 is_state_comment(&block, "Spar-Bot", "spar-bot"),
11098 "a login differs only in case"
11099 );
11100
11101 assert!(
11102 !is_state_comment(
11103 "why is there a `<!-- spar:state` block on this PR?",
11104 "maintainer",
11105 "spar-bot"
11106 ),
11107 "a question about the block was treated as the block"
11108 );
11109 assert!(
11110 !is_state_comment(&block, "maintainer", "spar-bot"),
11111 "somebody else's comment is not spar's to rewrite"
11112 );
11113 assert!(
11114 !is_state_comment(
11115 &format!("here is what spar writes:\n\n{block}"),
11116 "spar-bot",
11117 "spar-bot"
11118 ),
11119 "the marker has to open the body, not appear in it"
11120 );
11121 assert!(
11122 !is_state_comment(
11123 &format!("{STATE_MARKER}\n{{not json\n-->"),
11124 "spar-bot",
11125 "spar-bot"
11126 ),
11127 "a block that does not parse is not state"
11128 );
11129 assert!(
11130 !is_state_comment(&block, "spar-bot", ""),
11131 "an unknown viewer proves nothing about who wrote it"
11132 );
11133 }
11134
11135 #[test]
11136 fn a_malformed_state_comment_is_none_not_a_panic() {
11137 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
11138 }
11139
11140 #[test]
11141 fn atomic_write_leaves_no_temp_file() {
11142 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
11143 let _ = std::fs::remove_dir_all(&dir);
11144 let path = dir.join("state").join("pr-7.json");
11145 write_json_atomic(&path, &state()).unwrap();
11146 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
11147 .unwrap()
11148 .flatten()
11149 .filter_map(|e| e.file_name().to_str().map(str::to_string))
11150 .collect();
11151 assert_eq!(vec!["pr-7.json".to_string()], files);
11152 let _ = std::fs::remove_dir_all(&dir);
11153 }
11154
11155 #[test]
11156 fn atomic_write_overwrites_rather_than_accumulating() {
11157 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
11158 let _ = std::fs::remove_dir_all(&dir);
11159 let path = dir.join("pr-7.json");
11160 for round in 1..4 {
11161 let mut s = state();
11162 s.round = round;
11163 write_json_atomic(&path, &s).unwrap();
11164 }
11165 let back: PersistedState =
11166 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11167 assert_eq!(3, back.round);
11168 let _ = std::fs::remove_dir_all(&dir);
11169 }
11170
11171 #[test]
11172 fn style_from_env_defaults_to_enforcing() {
11173 std::env::remove_var("SPAR_BAN_EM_DASH");
11174 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
11175 let style = style_from_env();
11176 assert!(style.ban_em_dash && style.ban_ai_attribution);
11177 assert!(
11178 !style.terse,
11179 "the commit filter must not truncate a commit message"
11180 );
11181 }
11182
11183 #[test]
11188 fn a_push_denial_is_read_from_either_transport() {
11189 let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
11190 fatal: Could not read from remote repository.\n\n\
11191 Please make sure you have the correct access rights\n\
11192 and the repository exists.";
11193 let https = "remote: Permission to example/project.git denied to someone.\n\
11194 fatal: unable to access 'https://github.com/example/project.git/': The \
11195 requested URL returned error: 403";
11196 let denied = Some("Permission to example/project.git denied to someone.");
11197 assert_eq!(denied, push_denial(ssh));
11198 assert_eq!(denied, push_denial(https));
11199
11200 let stale = "To github.com:example/project.git\n \
11201 ! [rejected] HEAD -> issue-7 (stale info)\n\
11202 error: failed to push some refs to 'github.com:example/project.git'";
11203 assert_eq!(None, push_denial(stale));
11204
11205 let err = push_error(
11206 &PushTarget::Origin,
11207 "issue-7",
11208 &crate::error::SparError::new(ssh),
11209 );
11210 assert_eq!(
11211 "could not push to origin/issue-7. Permission to example/project.git denied to \
11212 someone.",
11213 err.to_string()
11214 );
11215 }
11216
11217 #[cfg(unix)]
11221 fn denied_fixture(
11222 tag: &str,
11223 fork_when_denied: bool,
11224 ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11225 use std::os::unix::fs::PermissionsExt;
11226 use std::sync::atomic::{AtomicU32, Ordering};
11227 static NEXT: AtomicU32 = AtomicU32::new(0);
11228 let id = NEXT.fetch_add(1, Ordering::Relaxed);
11229 let root =
11230 std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11231 let origin = root.join("origin.git");
11232 let fork = root.join("fork.git");
11233 let work = root.join("work");
11234 for dir in [&origin, &fork, &work] {
11235 std::fs::create_dir_all(dir).unwrap();
11236 }
11237 test_git(&origin, &["init", "--bare", "-b", "main"]);
11238 test_git(&fork, &["init", "--bare", "-b", "main"]);
11239 test_git(&work, &["init", "-b", "main"]);
11240 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11241 test_git(&work, &["config", "user.name", "spar test"]);
11242 test_git(&work, &["config", "commit.gpgsign", "false"]);
11243 std::fs::write(work.join("README.md"), "seed\n").unwrap();
11244 test_git(&work, &["add", "."]);
11245 test_git(&work, &["commit", "-m", "seed"]);
11246 test_git(
11247 &work,
11248 &["remote", "add", "origin", origin.to_str().unwrap()],
11249 );
11250 test_git(&work, &["push", "-q", "origin", "main"]);
11251 let hook = origin.join("hooks").join("pre-receive");
11253 std::fs::write(
11254 &hook,
11255 "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11256 )
11257 .unwrap();
11258 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11259 test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11260 std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11261 test_git(&work, &["add", "."]);
11262 test_git(&work, &["commit", "-m", "Fix it"]);
11263
11264 let mut text =
11265 String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11266 if !fork_when_denied {
11267 text.push_str("[loop]\nfork_when_denied = false\n");
11268 }
11269 let cfg = crate::config::parse(&text).unwrap();
11270 let repo = Repo::open(&work, &cfg).unwrap();
11271 repo.fork
11273 .set(Fork {
11274 owner: "someone".into(),
11275 full_name: "someone/project".into(),
11276 url: fork.to_str().unwrap().into(),
11277 })
11278 .unwrap();
11279 (ReviewFixture { root }, repo, work, origin, fork)
11280 }
11281
11282 #[cfg(unix)]
11283 #[test]
11284 fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11285 let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11286 let first = test_git(&work, &["rev-parse", "HEAD"]);
11287
11288 let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11289
11290 assert_eq!("someone:issue-7", target.head("issue-7"));
11291 assert_eq!(
11292 first.trim(),
11293 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11294 );
11295 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11296 .trim()
11297 .is_empty());
11298 assert_eq!(
11299 WriteSummary {
11300 attempted: 1,
11301 failed: 0
11302 },
11303 repo.write_summary(),
11304 "a refusal the fork answered is not a failed write"
11305 );
11306
11307 std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11310 test_git(&work, &["commit", "-qam", "Fix it again"]);
11311 repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11312 .unwrap();
11313 let second = test_git(&work, &["rev-parse", "HEAD"]);
11314 assert_eq!(
11315 second.trim(),
11316 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11317 );
11318 }
11319
11320 #[cfg(unix)]
11324 #[test]
11325 fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11326 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11327 test_git(
11328 &work,
11329 &[
11330 "push",
11331 "-q",
11332 fork.to_str().unwrap(),
11333 "main:refs/heads/issue-7",
11334 ],
11335 );
11336 let head = test_git(&work, &["rev-parse", "HEAD"]);
11337
11338 repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11339
11340 assert_eq!(
11341 head.trim(),
11342 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11343 );
11344 assert_eq!(0, repo.write_summary().failed);
11345 }
11346
11347 #[cfg(unix)]
11348 #[test]
11349 fn work_only_the_fork_has_is_not_published_over() {
11350 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11351 test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11352 std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11353 test_git(&work, &["add", "."]);
11354 test_git(&work, &["commit", "-qm", "Somebody's work"]);
11355 test_git(
11356 &work,
11357 &[
11358 "push",
11359 "-q",
11360 fork.to_str().unwrap(),
11361 "elsewhere:refs/heads/issue-7",
11362 ],
11363 );
11364 let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11365 test_git(&work, &["checkout", "-q", "issue-7"]);
11366
11367 let err = repo
11368 .publish_new_branch(&work, "issue-7", "main")
11369 .unwrap_err()
11370 .to_string();
11371
11372 assert!(
11373 err.contains(
11374 "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11375 ),
11376 "{err}"
11377 );
11378 assert!(err.contains("--delete issue-7"), "{err}");
11379 assert_eq!(
11380 theirs.trim(),
11381 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11382 );
11383 assert_eq!(
11384 WriteSummary {
11385 attempted: 1,
11386 failed: 1
11387 },
11388 repo.write_summary()
11389 );
11390 }
11391
11392 #[test]
11394 fn a_rejected_push_names_the_ref_it_rejected() {
11395 let stale = "To github.com:example/project.git\n \
11396 ! [rejected] HEAD -> issue-7 (stale info)\n\
11397 error: failed to push some refs to 'github.com:example/project.git'";
11398
11399 let err = push_error(
11400 &PushTarget::Origin,
11401 "issue-7",
11402 &crate::error::SparError::new(stale),
11403 )
11404 .to_string();
11405
11406 assert!(
11407 err.starts_with(
11408 "could not push to origin/issue-7. [rejected] HEAD -> issue-7 (stale info)"
11409 ),
11410 "{err}"
11411 );
11412 }
11413
11414 #[test]
11415 fn a_fork_is_reached_by_whatever_reaches_origin() {
11416 let cases = [
11417 (
11418 "git@github.com:example/project.git",
11419 Some("git@github.com:someone/project.git"),
11420 ),
11421 (
11423 "github.com:example/project.git",
11424 Some("github.com:someone/project.git"),
11425 ),
11426 (
11428 "git@github-work:example/project.git",
11429 Some("git@github-work:someone/project.git"),
11430 ),
11431 (
11433 "ssh://git@ssh.github.com:443/example/project.git",
11434 Some("ssh://git@ssh.github.com:443/someone/project.git"),
11435 ),
11436 (
11437 "https://github.com/example/project",
11438 Some("https://github.com/someone/project"),
11439 ),
11440 (
11441 "https://ghe.example.com/scm/example/project.git/",
11442 Some("https://ghe.example.com/scm/someone/project.git"),
11443 ),
11444 (
11445 "git@host:/srv/example/project.git",
11446 Some("git@host:/srv/someone/project.git"),
11447 ),
11448 ("/srv/git/project.git", None),
11449 ("https://github.com/project", None),
11450 ("", None),
11451 ];
11452 for (origin, want) in cases {
11453 assert_eq!(
11454 want.map(str::to_string),
11455 fork_url(origin, "someone/project"),
11456 "{origin}"
11457 );
11458 }
11459 }
11460
11461 #[test]
11462 fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11463 let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11464 "ssh_url":"git@github.com:someone/project.git",
11465 "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11466 let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11467 "sshUrl":"git@github.com:someone/project.git",
11468 "url":"https://github.com/someone/project"}]"#;
11469
11470 let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11471 assert_eq!("git@github-work:someone/project.git", fork.url);
11472 assert_eq!("someone", fork.owner);
11473 assert_eq!("someone/project", fork.full_name);
11474 let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11475 .unwrap()
11476 .unwrap();
11477 assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11478 assert_eq!(
11479 None,
11480 fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11481 );
11482
11483 let fork = fork_from_api(made, "https://github.com/project").unwrap();
11486 assert_eq!("https://github.com/someone/project.git", fork.url);
11487 let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11488 assert_eq!("git@github.com:someone/project.git", fork.url);
11489
11490 assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11491 assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11492 }
11493
11494 #[test]
11495 fn only_the_viewers_own_fork_can_be_pushed_to() {
11496 let pr = |owner: &str, name: &str| PrView {
11497 number: 9,
11498 url: String::new(),
11499 title: String::new(),
11500 body: String::new(),
11501 head_ref_name: "issue-7".into(),
11502 base_ref_name: "main".into(),
11503 state: "OPEN".into(),
11504 closing_issues_references: Vec::new(),
11505 is_cross_repository: true,
11506 head_repository_owner: Some(crate::model::Author {
11507 login: owner.into(),
11508 }),
11509 head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11510 };
11511 let origin = "git@github.com:example/project.git";
11512
11513 assert_eq!(
11514 Some(PushTarget::Fork(Fork {
11515 owner: "Someone".into(),
11516 full_name: "Someone/project".into(),
11517 url: "git@github.com:Someone/project.git".into(),
11518 })),
11519 own_fork_target(&pr("Someone", "project"), "someone", origin)
11520 );
11521 assert_eq!(
11522 None,
11523 own_fork_target(&pr("other", "project"), "someone", origin)
11524 );
11525 assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11526 assert_eq!(
11527 None,
11528 own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11529 );
11530 let mut unnamed = pr("someone", "project");
11531 unnamed.head_repository_owner = None;
11532 assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11533 }
11534
11535 #[cfg(unix)]
11538 #[test]
11539 fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11540 let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11541 test_git(
11542 &work,
11543 &[
11544 "push",
11545 "-q",
11546 fork.to_str().unwrap(),
11547 "issue-7:refs/heads/issue-7",
11548 ],
11549 );
11550 let published = test_git(&work, &["rev-parse", "issue-7"]);
11551 test_git(&work, &["checkout", "-q", "main"]);
11552 let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11553 let pr = PrView {
11554 number: 31,
11555 url: String::new(),
11556 title: String::new(),
11557 body: String::new(),
11558 head_ref_name: "issue-7".into(),
11559 base_ref_name: "main".into(),
11560 state: "OPEN".into(),
11561 closing_issues_references: Vec::new(),
11562 is_cross_repository: true,
11563 head_repository_owner: None,
11564 head_repository: None,
11565 };
11566
11567 let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11568
11569 assert_eq!("issue-7", branch);
11570 assert_eq!(
11571 published.trim(),
11572 test_git(&path, &["rev-parse", "HEAD"]).trim()
11573 );
11574 std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11575 test_git(&path, &["commit", "-qam", "Answer a comment"]);
11576 repo.push_to(&path, &target, &branch, Some(published.trim()))
11577 .unwrap();
11578 assert_eq!(
11579 test_git(&path, &["rev-parse", "HEAD"]).trim(),
11580 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11581 );
11582 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11583 .trim()
11584 .is_empty());
11585 }
11586
11587 #[cfg(unix)]
11588 #[test]
11589 fn with_forking_off_a_refused_push_says_why_and_how() {
11590 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11591
11592 let err = repo
11593 .publish_new_branch(&work, "issue-7", "main")
11594 .unwrap_err()
11595 .to_string();
11596
11597 assert!(
11598 err.contains("Permission to example/project.git denied to someone."),
11599 "{err}"
11600 );
11601 assert!(err.contains("fork_when_denied = true"), "{err}");
11602 assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11603 .trim()
11604 .is_empty());
11605 assert_eq!(
11606 WriteSummary {
11607 attempted: 1,
11608 failed: 1
11609 },
11610 repo.write_summary()
11611 );
11612 }
11613}
11614
11615#[cfg(test)]
11616mod comment_page_tests {
11617 use super::*;
11618
11619 #[test]
11620 fn a_single_merged_array_is_read() {
11621 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11622 assert_eq!(2, pages.len());
11623 assert_eq!(Some(2), pages[1]["id"].as_i64());
11624 }
11625
11626 #[test]
11627 fn concatenated_pages_from_an_older_gh_are_read_too() {
11628 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11629 assert_eq!(2, pages.len());
11630 }
11631
11632 #[test]
11636 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11637 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11638 let pages = parse_comment_pages(text);
11639 assert_eq!(2, pages.len(), "{pages:?}");
11640 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11641 }
11642
11643 #[test]
11644 fn empty_output_is_no_comments_not_a_panic() {
11645 assert!(parse_comment_pages("").is_empty());
11646 assert!(parse_comment_pages(" ").is_empty());
11647 assert!(parse_comment_pages("[]").is_empty());
11648 }
11649
11650 #[test]
11651 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11652 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11653 }
11654
11655 #[test]
11656 fn a_write_postcheck_rejects_truncated_comment_pages() {
11657 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11658 assert!(
11659 error.to_string().contains("unexpected comment pages"),
11660 "{error}"
11661 );
11662 }
11663
11664 #[test]
11665 fn a_write_postcheck_rejects_empty_or_non_array_output() {
11666 assert!(try_parse_comment_pages("").is_err());
11667 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11668 assert!(try_parse_comment_pages("[]").is_ok());
11669 }
11670
11671 #[test]
11672 fn state_is_found_in_the_last_matching_comment() {
11673 let payload = |round: u32| {
11674 format!(
11675 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11676 )
11677 };
11678 let text = serde_json::to_string(&serde_json::json!([
11679 {"id": 1, "body": payload(1)},
11680 {"id": 2, "body": "looks good to me"},
11681 {"id": 3, "body": payload(5)},
11682 ]))
11683 .unwrap();
11684 let pages = parse_comment_pages(&text);
11685 let last = pages
11686 .iter()
11687 .rev()
11688 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11689 .unwrap();
11690 assert_eq!(5, last.round);
11691 }
11692}
11693
11694#[cfg(test)]
11695mod linked_pr_tests {
11696 use super::*;
11697
11698 const REAL_PAYLOAD: &str = r#"[
11703 {"number":14252,"title":"fix: reject leading-dash branch names",
11704 "url":"https://github.com/cli/cli/pull/14252",
11705 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11706 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11707 "url":"https://github.com/cli/cli/issues/14238"}]},
11708 {"number":14217,"title":"another change",
11709 "url":"https://github.com/cli/cli/pull/14217",
11710 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11711 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11712 "url":"https://github.com/cli/cli/issues/9761"}]},
11713 {"number":14200,"title":"unlinked work",
11714 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11715 ]"#;
11716
11717 #[test]
11718 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11719 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11720 assert_eq!(14252, pr.number);
11721 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11722 }
11723
11724 #[test]
11725 fn the_right_pr_is_picked_out_of_several() {
11726 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11727 }
11728
11729 #[test]
11730 fn an_issue_nobody_is_working_on_finds_nothing() {
11731 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11732 }
11733
11734 #[test]
11735 fn an_unlinked_pr_is_never_matched() {
11736 for issue in [14200, 0, 1] {
11738 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11739 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11740 }
11741 }
11742 }
11743
11744 #[test]
11745 fn empty_or_broken_output_is_none_rather_than_a_panic() {
11746 assert!(find_linked_pr("", 1).is_none());
11747 assert!(find_linked_pr("[]", 1).is_none());
11748 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11749 assert!(find_linked_pr("[{\"number\":", 1).is_none());
11750 }
11751
11752 #[test]
11759 fn an_unreadable_list_is_not_an_empty_list() {
11760 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11761 assert!(linked_pr_rows("[{\"number\":").is_none());
11762 assert!(linked_pr_rows("").is_none());
11763
11764 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11765 assert!(empty.is_empty());
11766 assert!(pick_linked_pr(&empty, 14238).is_none());
11767
11768 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11769 assert_eq!(3, listed.len());
11770 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11771 }
11772
11773 #[test]
11775 fn pr_view_reads_the_cross_repository_flag() {
11776 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11777 "baseRefName":"main","state":"OPEN",
11778 "closingIssuesReferences":[],"isCrossRepository":true}"#;
11779 let pr: PrView = serde_json::from_str(json).unwrap();
11780 assert!(pr.is_cross_repository);
11781 assert!(pr.is_open());
11782
11783 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11784 assert!(
11785 !serde_json::from_str::<PrView>(&same_repo)
11786 .unwrap()
11787 .is_cross_repository
11788 );
11789 }
11790}
11791
11792#[cfg(test)]
11793mod min_number_tests {
11794 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11800 let mut numbers: Vec<i64> = open.to_vec();
11801 numbers.sort_unstable();
11802 if min_number > 0 {
11803 numbers.retain(|n| *n >= min_number);
11804 }
11805 numbers.truncate(limit);
11806 numbers
11807 }
11808
11809 #[test]
11810 fn the_floor_is_applied_before_the_cap_not_after() {
11811 let open = [12, 13, 14, 480, 481, 482];
11812 assert_eq!(vec![480, 481], pick(&open, 2, 480));
11813 assert!(!pick(&open, 2, 480).is_empty());
11816 }
11817
11818 #[test]
11819 fn no_floor_keeps_the_old_behaviour() {
11820 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11821 }
11822
11823 #[test]
11824 fn the_floor_is_inclusive() {
11825 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11826 }
11827
11828 #[test]
11829 fn a_floor_above_everything_open_yields_nothing() {
11830 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11831 }
11832}
11833
11834#[cfg(test)]
11835mod review_record_tests {
11836 use super::*;
11837 use serde_json::json;
11838
11839 #[test]
11840 fn prune_state_knows_which_files_belong_to_a_pull_request() {
11841 assert_eq!(Some(12), state_file_number("pr-12.json"));
11842 assert_eq!(Some(12), state_file_number("review-12.json"));
11843 assert_eq!(Some(12), state_file_number("checkin-12.json"));
11844 for shared in [
11845 "spend.json",
11846 "implementors.json",
11847 "contested.json",
11848 "listen.json",
11849 "pr-.json",
11850 "pr-12.json.tmp",
11851 "pr-x.json",
11852 ] {
11853 assert_eq!(None, state_file_number(shared), "{shared}");
11854 }
11855 }
11856
11857 #[test]
11858 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11859 let reviews = vec![
11860 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11861 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11862 ];
11863 assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11864 assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11865 assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11866 }
11867
11868 #[test]
11872 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11873 let older = vec![
11874 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11875 ];
11876 assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11877 let dismissed = vec![
11878 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11879 ];
11880 assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11881 }
11882
11883 #[test]
11884 fn a_response_splits_into_status_headers_and_body() {
11885 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";
11886 let resp = parse_api_response(text).expect("a response");
11887 assert_eq!(200, resp.status);
11888 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11889 assert_eq!(
11890 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11891 resp.headers.get("last-modified")
11892 );
11893 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11894
11895 let not_modified =
11896 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11897 assert_eq!(304, not_modified.status);
11898 assert!(not_modified.body.is_empty());
11899
11900 assert_eq!(None, parse_api_response(""));
11901 assert_eq!(None, parse_api_response("gh: command not found\n"));
11902 }
11903
11904 #[test]
11905 fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11906 let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11907 {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11908 {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11909 {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11910 {}
11911 ]}}}}})
11912 .to_string();
11913 let made = parse_review_requests(&text).expect("requests");
11914 assert_eq!(2, made.len());
11915 assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11916 assert_eq!("viewer", made[0].who.login);
11917 assert!(made[0].who.slug.is_empty());
11918 assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11919 assert!(made[1].who.login.is_empty());
11920 assert_eq!("example/ours", made[1].who.slug);
11921
11922 let none =
11923 json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11924 assert_eq!(
11925 Some(0),
11926 parse_review_requests(&none.to_string()).map(|m| m.len())
11927 );
11928 assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11929 assert!(parse_review_requests("gh: not json").is_none());
11930 }
11931
11932 #[test]
11933 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11934 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11935 let error = crate::error::SparError::call_failed("gh: boom");
11936 assert!(
11937 reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11938 );
11939
11940 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11941 let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11942 .unwrap_err();
11943 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11944
11945 let unreadable = Err(crate::error::SparError::new("no network"));
11946 let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11947 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11948 assert!(err.to_string().contains("could not be verified"), "{err}");
11949 }
11950 #[test]
11951 fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
11952 let comments = vec![
11953 InlineComment {
11954 path: "src/a.rs".into(),
11955 line: 12,
11956 start_line: None,
11957 body: "one".into(),
11958 },
11959 InlineComment {
11960 path: "src/b.rs".into(),
11961 line: 9,
11962 start_line: Some(4),
11963 body: "two".into(),
11964 },
11965 ];
11966 assert_eq!(
11967 json!({
11968 "commit_id": "abc",
11969 "event": "COMMENT",
11970 "body": "the body",
11971 "comments": [
11972 {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
11973 {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
11974 "start_side": "RIGHT", "body": "two"},
11975 ],
11976 }),
11977 review_payload("abc", "COMMENT", "the body", &comments)
11978 );
11979 }
11980
11981 #[test]
11982 fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
11983 let diff = r#"diff --git a/src/a.rs b/src/a.rs
11984index 1111111..2222222 100644
11985--- a/src/a.rs
11986+++ b/src/a.rs
11987@@ -1,3 +1,4 @@ fn x
11988 one
11989+++ an added line that looks like a header
11990 two
11991 three
11992@@ -20 +21,2 @@
11993-old
11994+new
11995+more
11996@@ -30,1 +32,0 @@
11997-gone
11998diff --git a/gone.rs b/gone.rs
11999deleted file mode 100644
12000--- a/gone.rs
12001+++ /dev/null
12002@@ -1,2 +0,0 @@
12003-a
12004-b
12005diff --git a/old.rs b/renamed.rs
12006similarity index 90%
12007rename from old.rs
12008rename to renamed.rs
12009--- a/old.rs
12010+++ b/renamed.rs
12011@@ -5,0 +6,1 @@
12012+inserted
12013diff --git a/with space.rs b/with space.rs
12014--- a/with space.rs
12015+++ b/with space.rs
12016@@ -2 +2 @@
12017-x
12018+y
12019diff --git "a/sp\tace.rs" "b/sp\tace.rs"
12020--- "a/sp\tace.rs"
12021+++ "b/sp\tace.rs"
12022@@ -1 +1 @@
12023-x
12024+y
12025"#;
12026 let hunks = new_side_hunks(diff);
12027 assert_eq!(
12028 Hunks::from([
12029 ("renamed.rs".to_string(), vec![(6, 6)]),
12030 ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
12031 ("with space.rs".to_string(), vec![(2, 2)]),
12032 ]),
12033 hunks
12034 );
12035 }
12036
12037 #[test]
12038 fn only_a_refusal_is_final_enough_to_send_a_review_again() {
12039 let error = |text: &str| crate::error::SparError::new(text);
12040 assert!(refused(&error(
12041 "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
12042 )));
12043 assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
12044 assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
12045 assert!(!refused(&error("command timed out after 300s")));
12046 }
12047
12048 #[test]
12049 fn a_hunk_header_with_its_counts_left_out_counts_one() {
12050 assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
12051 assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
12052 assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
12053 assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
12054 }
12055}