1use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{CheckCommand, Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20 Contested, ContestedItem, Followup, InlineComment, Issue, IssueRef, ItemKind, LiveHead,
21 PersistedState, PrRef, PrRow, PrSweepRow, PrView, PR_SWEEP_FIELDS,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, log, logdim, logwarn, spar_err};
27
28const ISSUE_FIELDS: &str = "number,title,body,labels,state,stateReason,url,author,comments";
36
37pub const FETCH_CEILING: usize = 500;
41
42pub const STATE_MARKER: &str = "<!-- spar:state";
45
46pub const COMMENT_MARKER: &str = "<!-- spar:comment -->";
54
55pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
63
64const WORKTREE_DIR: &str = ".spar-worktrees";
65const STATE_DIR: &str = ".spar";
66const READ_CHECKOUT_PREFIX: &str = "read-";
69
70pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
72pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub(crate) enum Disposable {
77 BuildOutput,
80 ReviewOutput,
92}
93
94pub(crate) fn is_spar_marker(path: &Path) -> bool {
102 let mut components = path.components();
103 let Some(std::path::Component::Normal(name)) = components.next() else {
104 return false;
105 };
106 if components.next().is_some() {
107 return false;
108 }
109 let Some(name) = name.to_str() else {
110 return false;
111 };
112 name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
113}
114
115pub(crate) fn is_spar_own_path(path: &Path) -> bool {
128 if is_spar_marker(path) {
129 return true;
130 }
131 let Some(std::path::Component::Normal(name)) = path.components().next() else {
132 return false;
133 };
134 name == OsStr::new(STATE_DIR)
135}
136
137const SPLIT_SLOTS: u32 = 20;
142
143const SETUP_TIMEOUT_SECS: u64 = 1800;
146
147#[derive(Debug, Clone)]
148pub struct SplitPushError {
149 message: String,
150 retain_worktree: bool,
151}
152
153impl SplitPushError {
154 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
155 Self {
156 message: message.into(),
157 retain_worktree,
158 }
159 }
160
161 pub fn retain_worktree(&self) -> bool {
162 self.retain_worktree
163 }
164}
165
166impl std::fmt::Display for SplitPushError {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.write_str(&self.message)
169 }
170}
171
172impl std::error::Error for SplitPushError {}
173
174fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
179 match attempt {
180 1 => format!("split-{parent}-{index}"),
181 n => format!("split-{parent}-{index}-{n}"),
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum PushTarget {
188 Origin,
189 Fork(Fork),
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Fork {
197 pub owner: String,
200 pub full_name: String,
202 pub url: String,
205}
206
207impl PushTarget {
208 pub fn head(&self, branch: &str) -> String {
210 match self {
211 PushTarget::Origin => branch.to_string(),
212 PushTarget::Fork(fork) => format!("{}:{branch}", fork.owner),
213 }
214 }
215
216 fn remote(&self) -> &str {
217 match self {
218 PushTarget::Origin => "origin",
219 PushTarget::Fork(fork) => &fork.url,
220 }
221 }
222
223 fn describe(&self, branch: &str) -> String {
224 match self {
225 PushTarget::Origin => format!("origin/{branch}"),
226 PushTarget::Fork(fork) => format!("{branch} on the fork {}", fork.full_name),
227 }
228 }
229}
230
231pub struct ReadCheckout<'a> {
244 repo: &'a Repo,
245 path: PathBuf,
246 checkpoint: Option<WorktreeCheckpoint>,
249}
250
251impl ReadCheckout<'_> {
252 pub fn path(&self) -> &Path {
253 &self.path
254 }
255}
256
257impl Drop for ReadCheckout<'_> {
258 fn drop(&mut self) {
259 if let Some(checkpoint) = self.checkpoint.take() {
260 self.repo.release_read_checkout(&self.path, &checkpoint);
261 }
262 }
263}
264
265#[derive(Debug)]
266pub struct Repo {
267 root: PathBuf,
268 pub style: Style,
269 pub branch_prefix: String,
270 pub state_store: StateStore,
271 pub followups: Followups,
272 pub drafts: Drafts,
273 pub fork_when_denied: bool,
275 setup: CheckCommand,
277 isolate_reads: bool,
280 keep_worktrees: bool,
282 fork: OnceLock<Fork>,
285 viewer: OnceLock<String>,
291 checkpoints: Mutex<BTreeMap<i64, u64>>,
296 writes: WriteStats,
297}
298
299#[derive(Debug, Default)]
300struct WriteStats {
301 attempted: AtomicUsize,
302 failed: AtomicUsize,
303}
304
305#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
306pub(crate) struct WriteSummary {
307 pub(crate) attempted: usize,
308 pub(crate) failed: usize,
309}
310
311impl WriteSummary {
312 pub(crate) fn succeeded(self) -> usize {
313 self.attempted.saturating_sub(self.failed)
314 }
315}
316
317#[derive(Debug, Clone)]
323pub(crate) struct WorktreeBaseline {
324 attributes: AttributeState,
325 ignored_untracked: IgnoredState,
326 git_state: GitState,
327}
328
329#[derive(Debug, Clone)]
335pub(crate) struct WorktreeCheckpoint {
336 path: PathBuf,
337 attributes: AttributeState,
338 git_state: GitState,
339 ignored_untracked: IgnoredState,
340}
341
342#[derive(Debug, Clone, Default, PartialEq, Eq)]
343pub(crate) struct AttributeState {
344 files: BTreeMap<PathBuf, [u8; 32]>,
345}
346
347#[derive(Debug, Clone, Default, PartialEq, Eq)]
352pub(crate) struct IgnoredState {
353 files: BTreeMap<PathBuf, UntrackedFile>,
354 ignored: BTreeSet<PathBuf>,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
364struct UntrackedFile {
365 kind: u8,
366 len: u64,
367 modified: Option<std::time::SystemTime>,
368 created: Option<std::time::SystemTime>,
369 readonly: bool,
370 symlink_target: Option<Vec<u8>>,
371 #[cfg(unix)]
372 device: u64,
373 #[cfg(unix)]
374 inode: u64,
375 #[cfg(unix)]
376 mode: u32,
377 #[cfg(unix)]
378 change_seconds: i64,
379 #[cfg(unix)]
380 change_nanoseconds: i64,
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Eq)]
384pub(crate) struct GitState {
385 repositories: BTreeMap<PathBuf, RepositoryState>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
389struct RepositoryState {
390 head: String,
391 unsafe_index_flags: Vec<u8>,
392 tracked: BTreeMap<PathBuf, TrackedEntry>,
393 gitlinks: BTreeMap<PathBuf, String>,
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
397struct TrackedEntry {
398 index_mode: String,
399 index_oid: String,
400 worktree: Option<WorktreeFile>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
404struct WorktreeFile {
405 mode: String,
406 #[cfg(unix)]
407 permissions: u32,
408 raw_oid: String,
409 fingerprint: [u8; 32],
410 content: [u8; 32],
411}
412
413struct Gitlink {
414 path: PathBuf,
415 oid: String,
416}
417
418struct IndexEntry {
419 path: PathBuf,
420 mode: String,
421 oid: String,
422}
423
424impl IgnoredState {
425 fn is_ignored(&self, path: &Path) -> bool {
426 self.ignored.contains(path)
427 }
428
429 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
430 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
431 paths.extend(after.files.keys().cloned());
432 paths
433 .into_iter()
434 .filter(|path| {
435 self.files.get(path) != after.files.get(path)
436 || self.is_ignored(path) != after.is_ignored(path)
437 })
438 .collect()
439 }
440
441 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
442 self.files
443 .iter()
444 .filter(|(path, state)| after.files.get(*path) != Some(*state))
445 .map(|(path, _)| path.clone())
446 .collect()
447 }
448
449 pub(crate) fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
450 after
451 .files
452 .keys()
453 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
454 .cloned()
455 .collect()
456 }
457
458 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
468 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
469 paths.extend(after.files.keys());
470 paths.into_iter().any(|path| {
471 if self.files.get(path) == after.files.get(path)
472 && self.is_ignored(path) == after.is_ignored(path)
473 {
474 return false;
475 }
476 !self.generated_move(after, path)
477 })
478 }
479
480 pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
508 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
509 paths.extend(after.files.keys());
510 let mut leavings = ReadOnlyLeavings::default();
511 for path in paths {
512 if self.files.get(path) == after.files.get(path)
513 && self.is_ignored(path) == after.is_ignored(path)
514 {
515 continue;
516 }
517 if !self.ignored_file_move(after, path) {
518 leavings.condemning.push(path.clone());
519 } else if !is_generated_artifact(path) {
520 leavings.tolerated.push(path.clone());
521 }
522 }
523 leavings
524 }
525
526 fn generated_move(&self, after: &Self, path: &Path) -> bool {
540 is_generated_artifact(path) && self.disposable_move(after, path)
541 }
542
543 fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
547 if path.starts_with(WORKTREE_DIR)
548 && self.files.get(path).is_some_and(|file| file.kind == 3)
549 && self.files.get(path) != after.files.get(path)
550 {
551 return false;
552 }
553 self.disposable_move(after, path)
554 }
555
556 fn disposable_move(&self, after: &Self, path: &Path) -> bool {
559 self.disposable(path) && after.disposable(path)
560 }
561
562 fn disposable(&self, path: &Path) -> bool {
565 !self.files.contains_key(path) || self.is_ignored(path)
566 }
567}
568
569impl GitState {
570 pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
576 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
577 prefixes.extend(after.repositories.keys());
578 let mut notes: Vec<String> = Vec::new();
579 let mut paths: Vec<PathBuf> = Vec::new();
580 for prefix in prefixes {
581 let label = describe_repository(prefix);
582 match (
583 self.repositories.get(prefix),
584 after.repositories.get(prefix),
585 ) {
586 (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
587 (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
588 (Some(before), Some(current)) => {
589 if before.head != current.head {
590 notes.push(format!("HEAD moved in {label}"));
591 }
592 if before.unsafe_index_flags != current.unsafe_index_flags {
593 notes.push(format!("index flags changed in {label}"));
594 }
595 paths.extend(
596 differing_keys(&before.gitlinks, ¤t.gitlinks)
597 .chain(differing_keys(&before.tracked, ¤t.tracked))
598 .map(|path| prefix.join(path)),
599 );
600 }
601 (None, None) => {}
602 }
603 }
604 if !paths.is_empty() {
605 paths.sort();
606 paths.dedup();
607 notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
608 }
609 (!notes.is_empty()).then(|| notes.join("; "))
610 }
611
612 pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
615 let difference = self.describe_difference(after)?;
616 let Some(moved) = self.moved_wholesale(after) else {
617 return Some(difference);
618 };
619 if said_once("moved", cwd, &moved) {
622 logwarn!(
623 "the checkout at {} moved while a read-only call was reading it: {moved}. The \
624 answer was kept, and it describes the tree as it stood before the move.",
625 cwd.display()
626 );
627 }
628 None
629 }
630
631 fn moved_wholesale(&self, after: &Self) -> Option<String> {
648 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
649 prefixes.extend(after.repositories.keys());
650 let mut notes: Vec<String> = Vec::new();
651 for prefix in prefixes {
652 let before = self.repositories.get(prefix)?;
654 let current = after.repositories.get(prefix)?;
655 if before.unsafe_index_flags != current.unsafe_index_flags
656 || before.gitlinks != current.gitlinks
657 {
658 return None;
659 }
660 let moved: Vec<&PathBuf> = differing_keys(&before.tracked, ¤t.tracked).collect();
661 if before.head == current.head {
662 if moved.is_empty() {
663 continue;
664 }
665 return None;
666 }
667 if !moved.iter().all(|path| {
668 checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
669 }) {
670 return None;
671 }
672 let label = describe_repository(prefix);
673 notes.push(match moved.len() {
674 0 => format!("HEAD moved in {label}"),
675 count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
676 });
677 }
678 (!notes.is_empty()).then(|| notes.join("; "))
679 }
680}
681
682fn checked_out(entry: Option<&TrackedEntry>) -> bool {
689 entry.is_none_or(|entry| {
690 entry
691 .worktree
692 .as_ref()
693 .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
694 })
695}
696
697pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
708 if paths.is_empty() {
709 return;
710 }
711 let named = list_paths(paths);
712 if !said_once("ignored", cwd, &named) {
713 return;
714 }
715 logdim!(
716 "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
717 project ignores these paths.",
718 paths.len(),
719 cwd.display()
720 );
721}
722
723fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
730 static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
731 SAID.lock()
732 .unwrap_or_else(std::sync::PoisonError::into_inner)
733 .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
734}
735
736fn describe_repository(prefix: &Path) -> String {
739 if prefix.as_os_str().is_empty() {
740 "the worktree".to_string()
741 } else {
742 format!("the submodule at {:?}", prefix.as_os_str())
743 }
744}
745
746fn differing_keys<'a, V: PartialEq>(
748 before: &'a BTreeMap<PathBuf, V>,
749 after: &'a BTreeMap<PathBuf, V>,
750) -> impl Iterator<Item = &'a PathBuf> {
751 let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
752 keys.extend(after.keys());
753 keys.into_iter()
754 .filter(move |key| before.get(*key) != after.get(*key))
755}
756
757#[derive(Default)]
763pub(crate) struct ReadOnlyLeavings {
764 pub(crate) tolerated: Vec<PathBuf>,
768 pub(crate) condemning: Vec<PathBuf>,
770}
771
772pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
776 match (first, second) {
777 (None, None) => None,
778 (Some(one), None) | (None, Some(one)) => Some(one),
779 (Some(first), Some(second)) => Some(format!("{first}; {second}")),
780 }
781}
782
783pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
788 const SHOWN: usize = 5;
789 let mut listed = paths
790 .iter()
791 .take(SHOWN)
792 .map(|path| format!("{:?}", path.as_os_str()))
793 .collect::<Vec<_>>()
794 .join(", ");
795 if paths.len() > SHOWN {
796 listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
797 }
798 listed
799}
800
801#[derive(Default)]
808struct IgnoredArtifacts {
809 new_paths: BTreeSet<PathBuf>,
810 changed_paths: BTreeSet<PathBuf>,
811 removed_paths: BTreeSet<PathBuf>,
812}
813
814impl IgnoredArtifacts {
815 fn left(&mut self, allowed: AllowedArtifacts) {
816 self.new_paths.extend(allowed.present);
817 self.removed_paths.extend(allowed.removed);
818 }
819
820 fn changed(&mut self, allowed: AllowedArtifacts) {
821 self.changed_paths.extend(allowed.present);
822 self.removed_paths.extend(allowed.removed);
823 }
824
825 fn report(&self, cwd: &Path) {
827 let paths: BTreeSet<_> = self
828 .new_paths
829 .iter()
830 .chain(&self.changed_paths)
831 .chain(&self.removed_paths)
832 .cloned()
833 .collect();
834 if !paths.is_empty() {
835 logdim!(
836 "the editing call changed {} ignored file(s) in {}: {}. They are outside \
837 the commit; cleanup checks them separately.",
838 paths.len(),
839 cwd.display(),
840 list_paths(&paths.into_iter().collect::<Vec<_>>())
841 );
842 }
843 }
844}
845
846#[derive(Default)]
850struct AllowedArtifacts {
851 present: Vec<PathBuf>,
852 removed: Vec<PathBuf>,
853}
854
855impl AllowedArtifacts {
856 fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
857 let (present, removed) = paths
858 .into_iter()
859 .partition(|path| after.files.contains_key(path));
860 Self { present, removed }
861 }
862}
863
864fn is_generated_artifact(path: &Path) -> bool {
870 const DIRECTORIES: &[&str] = &[
871 "target",
872 "dist",
873 "node_modules",
874 "__pycache__",
875 ".pytest_cache",
876 ".mypy_cache",
877 ".ruff_cache",
878 ".tox",
879 ".nox",
880 ".venv",
881 "venv",
882 ".gradle",
883 ".build",
884 "DerivedData",
885 ".next",
886 ".nuxt",
887 ".svelte-kit",
888 ".turbo",
889 "coverage",
890 ];
891 path.components().any(|component| {
892 let std::path::Component::Normal(name) = component else {
893 return false;
894 };
895 DIRECTORIES
896 .iter()
897 .any(|directory| name == OsStr::new(directory))
898 })
899}
900
901fn is_desktop_metadata(path: &Path) -> bool {
907 path.file_name() == Some(OsStr::new(".DS_Store"))
908}
909
910fn merge_pr_args<'a>(
911 number: &'a str,
912 expected_head: Option<&'a str>,
913 delete_branch: bool,
914) -> Vec<&'a str> {
915 let mut args = vec!["pr", "merge", number, "--squash"];
916 if delete_branch {
917 args.push("--delete-branch");
918 }
919 if let Some(expected_head) = expected_head {
920 args.extend(["--match-head-commit", expected_head]);
921 }
922 args
923}
924
925fn push_denial(message: &str) -> Option<&str> {
935 message
936 .lines()
937 .map(|line| {
938 let line = line.trim();
939 let line = line.strip_prefix("ERROR:").unwrap_or(line);
940 line.strip_prefix("remote:").unwrap_or(line).trim()
941 })
942 .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
943}
944
945fn push_rejection(message: &str) -> Option<&str> {
948 message
949 .lines()
950 .map(|line| line.trim().trim_start_matches('!').trim())
951 .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
952}
953
954const PUSH_RETRY_WAITS: [std::time::Duration; 2] = [
959 std::time::Duration::from_secs(10),
960 std::time::Duration::from_secs(30),
961];
962
963const REMOTE_FAULTS: &[&str] = &["fatal error", "internal server error", "internal error"];
967
968const CONNECTION_FAULTS: &[&str] = &[
970 "the remote end hung up unexpectedly",
971 "rpc failed",
972 "returned error: 5",
973 "connection reset",
974 "connection timed out",
975 "operation timed out",
976 "connection closed by",
977 "could not resolve host",
978 "early eof",
979 "unexpected disconnect",
980 "kex_exchange_identification",
981 "ssl_error_syscall",
982 "gnutls_handshake",
983 "http/2 stream",
984];
985
986fn transient_push_failure(message: &str) -> Option<&str> {
994 if push_denial(message).is_some() {
995 return None;
996 }
997 let lower = message.to_ascii_lowercase();
998 if lower.contains("returned error: 4") || lower.contains("rpc failed; http 4") {
999 return None;
1000 }
1001 if let Some(rejection) = push_rejection(message) {
1002 let reason = rejection.to_ascii_lowercase();
1003 let internal = rejection.starts_with("[remote rejected]")
1004 && REMOTE_FAULTS.iter().any(|fault| reason.contains(fault));
1005 return internal.then_some(rejection);
1006 }
1007 message.lines().map(str::trim).find(|line| {
1008 let line = line.to_ascii_lowercase();
1009 CONNECTION_FAULTS.iter().any(|fault| line.contains(fault))
1010 })
1011}
1012
1013fn retry_transient_push(
1021 label: &str,
1022 mut push: impl FnMut() -> Result<()>,
1023 mut landed: impl FnMut() -> bool,
1024 waits: &[std::time::Duration],
1025 mut sleep: impl FnMut(std::time::Duration) -> bool,
1026) -> Result<()> {
1027 let mut waits = waits.iter();
1028 loop {
1029 let error = match push() {
1030 Ok(()) => return Ok(()),
1031 Err(error) => error,
1032 };
1033 if !error.worth_retrying() {
1034 return Err(error);
1035 }
1036 let message = error.to_string();
1037 let Some(fault) = transient_push_failure(&message) else {
1038 return Err(error);
1039 };
1040 if landed() {
1041 log!("the push to {label} reported a failure but reached the remote, so it stands.");
1042 return Ok(());
1043 }
1044 let Some(wait) = waits.next() else {
1045 return Err(error);
1046 };
1047 log!(
1048 "the push to {label} failed on the remote's side: {fault}\nPushing again in {}s.",
1049 wait.as_secs()
1050 );
1051 if sleep(*wait) {
1052 return Err(error);
1053 }
1054 }
1055}
1056
1057fn push_error(
1058 target: &PushTarget,
1059 branch: &str,
1060 e: &crate::error::SparError,
1061) -> crate::error::SparError {
1062 let message = e.to_string();
1063 if let Some(denied) = push_denial(&message) {
1064 return spar_err!("could not push to {}. {denied}", target.describe(branch));
1065 }
1066 if let Some(fault) = transient_push_failure(&message) {
1067 return e.with_message(format!(
1068 "could not push to {}. {fault}\nThe remote failed on its own side rather than \
1069 refusing the branch, and pushing again did not get through. Try again later.",
1070 target.describe(branch)
1071 ));
1072 }
1073 spar_err!(
1074 "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
1075 target.describe(branch),
1076 push_rejection(&message).unwrap_or(e.last_line())
1077 )
1078}
1079
1080fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
1089 let origin = origin_url.trim();
1090 let path_start = match origin.find("://") {
1091 Some(scheme) => {
1092 let host = scheme + 3;
1093 host + origin[host..].find('/')? + 1
1094 }
1095 None => {
1096 let colon = origin.find(':')?;
1099 if origin[..colon].contains('/') {
1100 return None;
1101 }
1102 colon + 1
1103 }
1104 };
1105 let path = origin[path_start..].trim_end_matches('/');
1106 let (path, suffix) = match path.strip_suffix(".git") {
1107 Some(bare) => (bare, ".git"),
1108 None => (path, ""),
1109 };
1110 let segments: Vec<&str> = path.split('/').collect();
1111 if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
1112 return None;
1113 }
1114 let prefix = &segments[..segments.len() - 2];
1115 let prefix = if prefix.is_empty() {
1116 String::new()
1117 } else {
1118 format!("{}/", prefix.join("/"))
1119 };
1120 Some(format!(
1121 "{}{prefix}{full_name}{suffix}",
1122 &origin[..path_start]
1123 ))
1124}
1125
1126fn fork_reached_like_origin(
1131 owner: String,
1132 full_name: String,
1133 origin_url: &str,
1134 ssh_url: String,
1135 https_url: String,
1136) -> Fork {
1137 let origin = origin_url.trim();
1138 let url = fork_url(origin, &full_name).unwrap_or_else(|| {
1139 if origin.starts_with("git@") || origin.starts_with("ssh://") {
1140 ssh_url
1141 } else {
1142 https_url
1143 }
1144 });
1145 Fork {
1146 owner,
1147 full_name,
1148 url,
1149 }
1150}
1151
1152#[derive(Deserialize)]
1153struct ForkOwner {
1154 login: String,
1155}
1156
1157fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
1159 #[derive(Deserialize)]
1160 struct Answer {
1161 full_name: String,
1162 owner: ForkOwner,
1163 ssh_url: String,
1164 clone_url: String,
1165 }
1166
1167 let answer: Answer = serde_json::from_str(answer.trim())
1168 .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1169 Ok(fork_reached_like_origin(
1170 answer.owner.login,
1171 answer.full_name,
1172 origin_url,
1173 answer.ssh_url,
1174 answer.clone_url,
1175 ))
1176}
1177
1178fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1181 #[derive(Deserialize)]
1182 #[serde(rename_all = "camelCase")]
1183 struct Node {
1184 name_with_owner: String,
1185 owner: ForkOwner,
1186 ssh_url: String,
1187 url: String,
1188 }
1189
1190 let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1191 .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1192 Ok(nodes.into_iter().next().map(|node| {
1193 fork_reached_like_origin(
1194 node.owner.login,
1195 node.name_with_owner,
1196 origin_url,
1197 node.ssh_url,
1198 node.url,
1199 )
1200 }))
1201}
1202
1203fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1206 let owner = pr.head_repository_owner.as_ref()?.login.trim();
1207 let name = pr.head_repository.as_ref()?.name.trim();
1208 if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1209 return None;
1210 }
1211 let full_name = format!("{owner}/{name}");
1212 Some(PushTarget::Fork(Fork {
1213 owner: owner.to_string(),
1214 url: fork_url(origin_url, &full_name)?,
1215 full_name,
1216 }))
1217}
1218
1219fn reconcile_pr_creation(
1220 branch: &str,
1221 created: Result<String>,
1222 found: Result<Option<PrRef>>,
1223) -> Result<PrRef> {
1224 match (created, found) {
1225 (_, Ok(Some(pr))) => Ok(pr),
1226 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1227 "PR creation reported success but none was found for {branch}"
1228 ))),
1229 (Err(create), Ok(None)) => Err(spar_err!(
1230 "could not open a PR for {branch}. {}",
1231 create.last_line()
1232 )),
1233 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1234 "PR creation reported success for {branch}, but it could not be verified. {}",
1235 check.last_line()
1236 ))),
1237 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1238 "could not open a PR for {branch}. {} The result could not be verified: {}",
1239 create.last_line(),
1240 check.last_line()
1241 ))),
1242 }
1243}
1244
1245fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1246 #[derive(Deserialize)]
1247 #[serde(rename_all = "camelCase")]
1248 struct Row {
1249 number: i64,
1250 #[serde(default)]
1251 url: String,
1252 #[serde(default)]
1253 title: String,
1254 base_ref_name: String,
1255 }
1256
1257 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1258 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1259 })?;
1260 Ok(rows
1261 .into_iter()
1262 .find(|row| row.base_ref_name == base)
1263 .map(|row| PrRef {
1264 number: row.number,
1265 url: row.url,
1266 title: row.title,
1267 }))
1268}
1269
1270fn signed(body: &str) -> String {
1273 if body.contains(COMMENT_MARKER) {
1274 return body.to_string();
1275 }
1276 format!("{body}\n\n{COMMENT_MARKER}")
1277}
1278
1279pub fn is_spar_comment(body: &str) -> bool {
1281 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1282}
1283
1284#[derive(Debug, Clone, Default, PartialEq, Eq)]
1286pub struct ApiResponse {
1287 pub status: u16,
1288 pub headers: BTreeMap<String, String>,
1290 pub body: String,
1291}
1292
1293pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1297 let mut lines = stdout.split_inclusive('\n');
1298 let first = lines.next()?.trim_end_matches(['\r', '\n']);
1299 if !first.starts_with("HTTP/") {
1300 return None;
1301 }
1302 let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1303 let mut headers = BTreeMap::new();
1304 let mut body = String::new();
1305 let mut in_body = false;
1306 for line in lines {
1307 if in_body {
1308 body.push_str(line);
1309 continue;
1310 }
1311 let bare = line.trim_end_matches(['\r', '\n']);
1312 if bare.is_empty() {
1313 in_body = true;
1314 continue;
1315 }
1316 if let Some((name, value)) = bare.split_once(':') {
1317 headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1318 }
1319 }
1320 Some(ApiResponse {
1321 status,
1322 headers,
1323 body,
1324 })
1325}
1326
1327const REQUESTS_QUERY: &str = "\
1330query($owner: String!, $repo: String!, $number: Int!) {
1331 repository(owner: $owner, name: $repo) {
1332 pullRequest(number: $number) {
1333 timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1334 nodes {
1335 ... on ReviewRequestedEvent {
1336 createdAt
1337 requestedReviewer {
1338 ... on User { login }
1339 ... on Team { combinedSlug }
1340 }
1341 }
1342 }
1343 }
1344 }
1345 }
1346}";
1347
1348pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1352 let v: Value = serde_json::from_str(text).ok()?;
1353 let nodes = v
1354 .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1355 .as_array()?;
1356 let text_at = |node: &Value, path: &str| {
1357 node.pointer(path)
1358 .and_then(Value::as_str)
1359 .unwrap_or_default()
1360 .to_string()
1361 };
1362 Some(
1363 nodes
1364 .iter()
1365 .map(|node| crate::model::ReviewRequested {
1366 at: text_at(node, "/createdAt"),
1367 who: crate::model::Login {
1368 login: text_at(node, "/requestedReviewer/login"),
1369 slug: text_at(node, "/requestedReviewer/combinedSlug"),
1370 },
1371 })
1372 .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1373 .collect(),
1374 )
1375}
1376
1377const ASSIGNMENT_FIELDS: &str = "
1381 number
1382 title
1383 assignees(first: 100) { nodes { login } }
1384 timelineItems(itemTypes: [ASSIGNED_EVENT], last: 20) {
1385 nodes {
1386 ... on AssignedEvent {
1387 createdAt
1388 assignee {
1389 ... on User { login }
1390 ... on Bot { login }
1391 ... on Mannequin { login }
1392 }
1393 }
1394 }
1395 }";
1396
1397fn assigned_issues_query() -> String {
1401 format!(
1402 "query($owner: String!, $repo: String!, $login: String!) {{
1403 repository(owner: $owner, name: $repo) {{
1404 issues(states: OPEN, filterBy: {{assignee: $login}}, first: 100, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{
1405 nodes {{{ASSIGNMENT_FIELDS}
1406 }}
1407 }}
1408 }}
1409}}"
1410 )
1411}
1412
1413fn issue_assignments_query() -> String {
1415 format!(
1416 "query($owner: String!, $repo: String!, $number: Int!) {{
1417 repository(owner: $owner, name: $repo) {{
1418 issue(number: $number) {{{ASSIGNMENT_FIELDS}
1419 }}
1420 }}
1421}}"
1422 )
1423}
1424
1425fn assigned_issue(node: &Value) -> Option<crate::model::AssignedIssue> {
1427 let text_at = |node: &Value, path: &str| {
1428 node.pointer(path)
1429 .and_then(Value::as_str)
1430 .unwrap_or_default()
1431 .to_string()
1432 };
1433 let nodes = |path: &str| {
1434 node.pointer(path)
1435 .and_then(Value::as_array)
1436 .cloned()
1437 .unwrap_or_default()
1438 };
1439 Some(crate::model::AssignedIssue {
1440 number: node.pointer("/number")?.as_i64()?,
1441 title: text_at(node, "/title"),
1442 assignees: nodes("/assignees/nodes")
1443 .iter()
1444 .map(|a| text_at(a, "/login"))
1445 .filter(|login| !login.is_empty())
1446 .collect(),
1447 assigned: nodes("/timelineItems/nodes")
1450 .iter()
1451 .map(|e| (text_at(e, "/createdAt"), text_at(e, "/assignee/login")))
1452 .filter(|(at, who)| !at.is_empty() && !who.is_empty())
1453 .collect(),
1454 })
1455}
1456
1457pub fn parse_assigned_issues(text: &str) -> Option<Vec<crate::model::AssignedIssue>> {
1460 let v: Value = serde_json::from_str(text).ok()?;
1461 let nodes = v.pointer("/data/repository/issues/nodes")?.as_array()?;
1462 Some(nodes.iter().filter_map(assigned_issue).collect())
1463}
1464
1465pub fn parse_issue_assignments(text: &str) -> Option<crate::model::AssignedIssue> {
1468 let v: Value = serde_json::from_str(text).ok()?;
1469 assigned_issue(v.pointer("/data/repository/issue")?)
1470}
1471
1472pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1476 let stem = name.strip_suffix(".json")?;
1477 ["pr-", "review-", "checkin-"]
1478 .iter()
1479 .find_map(|prefix| stem.strip_prefix(prefix))
1480 .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1481 .and_then(|digits| digits.parse().ok())
1482}
1483
1484fn has_viewer_review(reviews: &[Value], viewer: &str, bodies: &[&str], commit: &str) -> bool {
1492 viewer_review(reviews, viewer, bodies, commit).is_some()
1493}
1494
1495fn viewer_review<'a>(
1497 reviews: &'a [Value],
1498 viewer: &str,
1499 bodies: &[&str],
1500 commit: &str,
1501) -> Option<&'a Value> {
1502 reviews.iter().find(|review| {
1503 let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1504 let standing = !matches!(
1505 field("state").to_uppercase().as_str(),
1506 "DISMISSED" | "PENDING"
1507 );
1508 review
1509 .get("user")
1510 .and_then(|u| u.get("login"))
1511 .and_then(Value::as_str)
1512 .is_some_and(|login| crate::comments::same_login(login, viewer))
1513 && bodies.contains(&field("body"))
1514 && field("commit_id").eq_ignore_ascii_case(commit)
1515 && standing
1516 })
1517}
1518
1519fn reconcile_review_post(
1520 number: i64,
1521 bodies: &[&str],
1522 viewer: &str,
1523 commit: &str,
1524 post_error: crate::error::SparError,
1525 reviews: Result<Vec<Value>>,
1526) -> Result<Option<String>> {
1527 match reviews {
1528 Ok(reviews) => match viewer_review(&reviews, viewer, bodies, commit) {
1529 Some(review) => Ok(html_url(review)),
1530 None => Err(post_error),
1531 },
1532 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1533 "could not submit a review on #{number}. {} The result could not be verified: {}",
1534 post_error.last_line(),
1535 read_error.last_line()
1536 ))),
1537 }
1538}
1539
1540fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1545 let comments: Vec<Value> = comments
1546 .iter()
1547 .map(|c| {
1548 let mut row = serde_json::json!({
1549 "path": c.path,
1550 "line": c.line,
1551 "side": "RIGHT",
1552 "body": c.body,
1553 });
1554 if let Some(start) = c.start_line {
1555 row["start_line"] = serde_json::json!(start);
1556 row["start_side"] = serde_json::json!("RIGHT");
1557 }
1558 row
1559 })
1560 .collect();
1561 serde_json::json!({
1562 "commit_id": commit,
1563 "event": event,
1564 "body": body,
1565 "comments": comments,
1566 })
1567}
1568
1569fn refused(e: &crate::error::SparError) -> bool {
1573 e.to_string().contains("(HTTP 422)")
1574}
1575
1576pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1580
1581fn new_side_hunks(diff: &str) -> Hunks {
1588 let mut out = Hunks::new();
1589 let mut path: Option<String> = None;
1590 let (mut old_left, mut new_left) = (0u32, 0u32);
1591 for line in diff.lines() {
1592 if old_left > 0 || new_left > 0 {
1593 match line.as_bytes().first() {
1594 Some(b'+') => new_left = new_left.saturating_sub(1),
1595 Some(b'-') => old_left = old_left.saturating_sub(1),
1596 Some(b'\\') => {}
1597 _ => {
1598 old_left = old_left.saturating_sub(1);
1599 new_left = new_left.saturating_sub(1);
1600 }
1601 }
1602 continue;
1603 }
1604 if line.starts_with("diff --git ") {
1605 path = None;
1606 } else if let Some(rest) = line.strip_prefix("+++ ") {
1607 let rest = if rest.contains(' ') {
1610 rest.strip_suffix('\t').unwrap_or(rest)
1611 } else {
1612 rest
1613 };
1614 path = rest
1615 .strip_prefix("b/")
1616 .filter(|p| !p.is_empty())
1617 .map(str::to_string);
1618 } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1619 old_left = old_len;
1620 new_left = len;
1621 if let (Some(path), true) = (&path, len > 0 && start > 0) {
1622 out.entry(path.clone())
1623 .or_default()
1624 .push((start, start + len - 1));
1625 }
1626 }
1627 }
1628 out
1629}
1630
1631fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1633 let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1634 let (old, new) = ranges.split_once(" +")?;
1635 let range = |s: &str| -> Option<(u32, u32)> {
1636 match s.split_once(',') {
1637 Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1638 None => Some((s.parse().ok()?, 1)),
1639 }
1640 };
1641 Some((range(old)?, range(new)?))
1642}
1643
1644fn exact_comment<'a>(comments: &'a [Value], body: &str) -> Option<&'a Value> {
1645 comments.iter().find(|comment| {
1646 comment
1647 .get("body")
1648 .and_then(Value::as_str)
1649 .is_some_and(|seen| seen == body)
1650 })
1651}
1652
1653fn html_url(row: &Value) -> Option<String> {
1655 row.get("html_url")
1656 .and_then(Value::as_str)
1657 .filter(|url| !url.is_empty())
1658 .map(str::to_string)
1659}
1660
1661fn printed_url(stdout: &str) -> Option<String> {
1664 stdout
1665 .lines()
1666 .map(str::trim)
1667 .find(|line| line.starts_with("https://"))
1668 .map(str::to_string)
1669}
1670
1671fn review_url(response: &str) -> Option<String> {
1673 html_url(&serde_json::from_str(response).ok()?)
1674}
1675
1676fn reconcile_comment_post(
1677 number: i64,
1678 body: &str,
1679 post_error: crate::error::SparError,
1680 comments: Result<Vec<Value>>,
1681) -> Result<Option<String>> {
1682 match comments {
1683 Ok(comments) => match exact_comment(&comments, body) {
1684 Some(comment) => Ok(html_url(comment)),
1685 None => Err(post_error),
1686 },
1687 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1688 "could not comment on #{number}. {} The result could not be verified: {}",
1689 post_error.last_line(),
1690 read_error.last_line()
1691 ))),
1692 }
1693}
1694
1695fn reconcile_issue_edit(
1696 number: i64,
1697 wanted: &str,
1698 edit_error: crate::error::SparError,
1699 observed: Result<String>,
1700) -> Result<()> {
1701 match observed {
1702 Ok(body) if body == wanted => Ok(()),
1703 Ok(_) => Err(spar_err!(
1704 "could not rewrite the body of #{number}. {}",
1705 edit_error.last_line()
1706 )),
1707 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1708 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1709 edit_error.last_line(),
1710 read_error.last_line()
1711 ))),
1712 }
1713}
1714
1715fn issue_url_has_number(url: &str) -> bool {
1716 url.trim()
1717 .rsplit('/')
1718 .next()
1719 .and_then(|tail| tail.parse::<i64>().ok())
1720 .is_some_and(|number| number > 0)
1721}
1722
1723fn reconcile_issue_creation(
1724 title: &str,
1725 created: Result<String>,
1726 found: Result<Option<ExistingIssue>>,
1727) -> Result<String> {
1728 match (created, found) {
1729 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1730 (_, Ok(Some(issue))) => Ok(issue.url),
1731 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1732 "issue creation reported success but no matching issue was found for {title:?}"
1733 ))),
1734 (Err(create), Ok(None)) => Err(spar_err!(
1735 "could not file issue {title:?}. {}",
1736 create.last_line()
1737 )),
1738 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1739 "issue creation reported success for {title:?}, but it could not be verified. {}",
1740 check.last_line()
1741 ))),
1742 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1743 "could not file issue {title:?}. {} The result could not be verified: {}",
1744 create.last_line(),
1745 check.last_line()
1746 ))),
1747 }
1748}
1749
1750fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1751 if output.trim().is_empty() {
1752 return Ok(None);
1753 }
1754 for line in output.lines() {
1755 let mut fields = line.split_whitespace();
1756 let oid = fields.next().unwrap_or_default();
1757 let name = fields.next().unwrap_or_default();
1758 if name == remote_ref && !oid.is_empty() {
1759 return Ok(Some(oid.to_string()));
1760 }
1761 }
1762 Err(spar_err!(
1763 "origin returned an unexpected ref listing for {remote_ref}"
1764 ))
1765}
1766
1767fn reconcile_failed_split_push(
1768 branch: &str,
1769 push_error: crate::error::SparError,
1770 local: Result<String>,
1771 remote: Result<String>,
1772) -> std::result::Result<(), SplitPushError> {
1773 let remote_ref = format!("refs/heads/{branch}");
1774 match (local, remote) {
1775 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1776 Ok(Some(oid)) if oid == local.trim() => Ok(()),
1777 Ok(_) => Err(SplitPushError::new(
1778 format!(
1779 "could not create origin/{branch}. {} The remote branch is absent or points \
1780 somewhere else. Nothing was overwritten.",
1781 push_error.last_line()
1782 ),
1783 false,
1784 )),
1785 Err(check) => Err(SplitPushError::new(
1786 format!(
1787 "could not confirm whether origin/{branch} was created. {} The remote result \
1788 could not be verified: {}",
1789 push_error.last_line(),
1790 check.last_line()
1791 ),
1792 true,
1793 )),
1794 },
1795 (local, remote) => {
1796 let check = match (local, remote) {
1797 (Err(local), Err(remote)) => format!(
1798 "the local commit could not be read: {}; origin could not be read: {}",
1799 local.last_line(),
1800 remote.last_line()
1801 ),
1802 (Err(local), _) => {
1803 format!("the local commit could not be read: {}", local.last_line())
1804 }
1805 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1806 _ => unreachable!(),
1807 };
1808 Err(SplitPushError::new(
1809 format!(
1810 "could not confirm whether origin/{branch} was created. {} The result could \
1811 not be verified because {check}",
1812 push_error.last_line()
1813 ),
1814 true,
1815 ))
1816 }
1817 }
1818}
1819
1820impl Repo {
1821 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1822 let root =
1823 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1824 let inside = proc::run_str(
1827 &["git", "rev-parse", "--is-inside-work-tree"],
1828 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1829 )
1830 .unwrap_or_default();
1831 if inside.trim() != "true" {
1832 bail!("not a git repository: {}", root.display());
1833 }
1834 let repo = Self {
1835 root,
1836 style: cfg.style.clone(),
1837 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1838 state_store: cfg.loop_cfg.state_store,
1839 followups: cfg.loop_cfg.followups,
1840 drafts: cfg.loop_cfg.drafts,
1841 fork_when_denied: cfg.loop_cfg.fork_when_denied,
1842 setup: cfg.loop_cfg.setup.clone(),
1843 isolate_reads: cfg.loop_cfg.worktrees,
1844 keep_worktrees: cfg.loop_cfg.keep_worktrees,
1845 fork: OnceLock::new(),
1846 viewer: OnceLock::new(),
1847 checkpoints: Mutex::new(BTreeMap::new()),
1848 writes: WriteStats::default(),
1849 };
1850 repo.self_exclude();
1851 Ok(repo)
1852 }
1853
1854 fn self_exclude(&self) {
1862 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1863 let git_dir = git_dir.trim();
1864 if git_dir.is_empty() {
1865 return;
1866 }
1867 let path = Path::new(git_dir).join("info").join("exclude");
1868 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1869
1870 let wanted = [
1875 format!("/{WORKTREE_DIR}/"),
1876 format!("/{STATE_DIR}/"),
1877 format!("{RECOVERY_MARKER_PREFIX}*"),
1878 ];
1879 let missing: Vec<&String> = wanted
1880 .iter()
1881 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1882 .collect();
1883 if missing.is_empty() {
1884 return;
1885 }
1886
1887 use std::io::Write;
1888 if let Some(parent) = path.parent() {
1889 let _ = std::fs::create_dir_all(parent);
1890 }
1891 let mut block = String::new();
1892 if !existing.is_empty() && !existing.ends_with('\n') {
1893 block.push('\n');
1894 }
1895 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1896 for line in missing {
1897 block.push_str(line);
1898 block.push('\n');
1899 }
1900 if let Ok(mut file) = std::fs::OpenOptions::new()
1901 .create(true)
1902 .append(true)
1903 .open(&path)
1904 {
1905 let _ = file.write_all(block.as_bytes());
1906 }
1907 }
1908
1909 pub fn root(&self) -> &Path {
1910 &self.root
1911 }
1912
1913 pub(crate) fn write_summary(&self) -> WriteSummary {
1914 WriteSummary {
1915 attempted: self.writes.attempted.load(Ordering::Relaxed),
1916 failed: self.writes.failed.load(Ordering::Relaxed),
1917 }
1918 }
1919
1920 pub(crate) fn record_write<T, E>(
1921 &self,
1922 result: std::result::Result<T, E>,
1923 ) -> std::result::Result<T, E> {
1924 self.record_write_outcome(result.is_err());
1925 result
1926 }
1927
1928 pub(crate) fn record_failed_write<T, E>(
1929 &self,
1930 result: std::result::Result<T, E>,
1931 ) -> std::result::Result<T, E> {
1932 if result.is_err() {
1933 self.record_write_outcome(true);
1934 }
1935 result
1936 }
1937
1938 fn record_write_outcome(&self, failed: bool) {
1939 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1940 if failed {
1941 self.writes.failed.fetch_add(1, Ordering::Relaxed);
1942 }
1943 }
1944
1945 pub fn clean(&self, text: &str) -> Result<String> {
1951 let out = style::scrub(text, &self.style);
1952 let bad = style::violations(&out, &self.style);
1953 if !bad.is_empty() {
1954 bail!(
1955 "style gate could not clean text ({}): {}",
1956 bad.join(", "),
1957 style::clip(&out, 300)
1958 );
1959 }
1960 Ok(out)
1961 }
1962
1963 pub fn clean_body(&self, text: &str) -> Result<String> {
1965 self.clean(&style::body(text, &self.style))
1966 }
1967
1968 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1970 self.clean(&style::issue_body(text, &self.style))
1971 }
1972
1973 pub fn clean_title(&self, text: &str) -> Result<String> {
1984 Ok(style::title(&self.clean(text)?, &self.style))
1985 }
1986
1987 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1988 let title = self.record_failed_write(self.clean_title(text))?;
1989 if title.trim().is_empty() {
1990 return self.record_failed_write(Err(spar_err!(
1991 "nothing left of the title after cleaning it"
1992 )));
1993 }
1994 Ok(title)
1995 }
1996
1997 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1998 if self.followups == Followups::Issues {
1999 self.clean_nonempty_title_for_write(text)
2000 } else {
2001 self.clean_title(text)
2002 }
2003 }
2004
2005 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
2008 ExecOpts::new()
2009 .cwd(cwd.unwrap_or(&self.root))
2010 .check(check)
2011 .timeout_secs(600)
2012 }
2013
2014 pub fn git(&self, args: &[&str]) -> Result<String> {
2015 self.git_at(None, args)
2016 }
2017
2018 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
2019 let argv = git_without_maintenance_argv(args);
2020 proc::run(&argv, &self.git_opts(cwd, true))
2021 }
2022
2023 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
2029 let argv = git_without_automation_argv(args);
2030 proc::run(
2031 &argv,
2032 &self.git_opts(Some(cwd), true).stop_descendants(true),
2033 )
2034 }
2035
2036 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
2037 let argv = git_without_automation_argv(args);
2038 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
2039 .map(|output| output.ok())
2040 }
2041
2042 pub fn git_try(&self, args: &[&str]) -> String {
2044 self.git_try_at(None, args)
2045 }
2046
2047 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
2048 let argv = git_without_maintenance_argv(args);
2049 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
2050 }
2051
2052 pub fn default_branch(&self, configured: &str) -> String {
2055 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
2056 match refname.trim().rsplit('/').next() {
2057 Some(name) if !name.is_empty() => name.to_string(),
2058 _ => configured.to_string(),
2059 }
2060 }
2061
2062 pub fn branch_for_issue(&self, issue: i64) -> String {
2070 format!("{}issue-{issue}", self.branch_prefix)
2071 }
2072
2073 pub fn branch_for_pr(&self, number: i64) -> String {
2074 format!("{}pr-{number}", self.branch_prefix)
2075 }
2076
2077 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
2086 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
2087 }
2088
2089 fn ledger_path(&self) -> PathBuf {
2090 self.root.join(STATE_DIR).join("branches.json")
2091 }
2092
2093 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
2094 std::fs::read_to_string(self.ledger_path())
2095 .ok()
2096 .and_then(|text| serde_json::from_str(&text).ok())
2097 .unwrap_or_default()
2098 }
2099
2100 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
2101 let mut data = self.known_branches();
2102 data.insert(
2103 branch.to_string(),
2104 BranchRecord {
2105 kind: kind.to_string(),
2106 number,
2107 },
2108 );
2109 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
2110 logdim!("could not record branch {branch}: {e}");
2111 }
2112 }
2113
2114 pub fn forget_branch(&self, branch: &str) {
2115 let mut data = self.known_branches();
2116 if data.remove(branch).is_none() {
2117 return;
2118 }
2119 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
2120 logdim!("could not update the branch record: {e}");
2121 }
2122 }
2123
2124 fn worktree_path(&self, name: &str) -> PathBuf {
2127 self.root.join(WORKTREE_DIR).join(name)
2128 }
2129
2130 fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
2137 let argv = self.setup.argv();
2138 if argv.is_empty() {
2139 return Ok(());
2140 }
2141 let command = self.setup.describe();
2142 logdim!("setting up the {label}: {command}");
2143 let out = proc::exec(
2144 &argv,
2145 &ExecOpts::new()
2146 .cwd(path)
2147 .check(false)
2148 .timeout_secs(SETUP_TIMEOUT_SECS)
2149 .stop_descendants(true),
2150 )
2151 .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
2152 if !out.ok() {
2153 let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
2154 bail!(
2155 "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
2156 work in it.\n{}",
2157 path.display(),
2158 style::clip(text.trim(), 4000)
2159 );
2160 }
2161 Ok(())
2162 }
2163
2164 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
2166 let branch = self.branch_for_issue(issue);
2167 let path = self.worktree_path(&format!("issue-{issue}"));
2168
2169 self.refuse_issue_branch_rebuild(issue, base)?;
2170 self.refuse_dirty_worktree(
2171 &path,
2172 &format!("worktree for issue #{issue}"),
2173 Disposable::BuildOutput,
2174 )?;
2175
2176 if !self.branch_deletion_is_safe(&branch)? {
2177 bail!(
2178 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
2179 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2180 before retrying."
2181 );
2182 }
2183
2184 if !self.remove_worktree_at(&path)? {
2185 bail!(
2186 "the existing worktree for issue #{issue} could not be removed safely. Its \
2187 branch was kept."
2188 );
2189 }
2190 if !self.delete_branch_if_safe(&branch)? {
2191 bail!(
2192 "the existing branch {branch} changed or remained checked out while its \
2193 worktree was being rebuilt. It was kept."
2194 );
2195 }
2196
2197 if let Some(parent) = path.parent() {
2198 std::fs::create_dir_all(parent)
2199 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2200 }
2201
2202 let path_str = path.display().to_string();
2203 let remote_start = format!("origin/{base}");
2204 let created = self
2205 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
2206 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
2207
2208 created.map_err(|e| {
2211 spar_err!(
2212 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
2213 and does `origin` exist?",
2214 e.last_line()
2215 )
2216 })?;
2217 self.record_branch(&branch, "issue", issue);
2218 self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
2219 Ok((path, branch))
2220 }
2221
2222 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
2229 let branch = self.branch_for_issue(issue);
2230 let base_remote_ref = format!("refs/heads/{base}");
2231 let base_tracking_ref = format!("refs/remotes/origin/{base}");
2232 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
2233 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
2234 .map_err(|e| {
2235 spar_err!(
2236 "could not refresh origin/{base} before checking issue #{issue}: {}",
2237 e.last_line()
2238 )
2239 })?;
2240
2241 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
2242 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
2243 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
2244 bail!(
2245 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
2246 pull request accounts for them. Rebuilding it would force push over that \
2247 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
2248 it, or delete it with `git push origin --delete {branch}` if the remote \
2249 branch is no longer needed."
2250 );
2251 }
2252 }
2253
2254 if self.fork_when_denied && self.origin_refuses_push() {
2257 match self.existing_fork() {
2258 Ok(Some(fork)) => {
2259 self.fork_branch_lease(&fork, &branch, base)?;
2260 }
2261 Ok(None) => {}
2262 Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
2263 }
2264 }
2265
2266 let local_ref = format!("refs/heads/{branch}");
2267 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2268 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
2269 let recorded_pr = self
2270 .known_branches()
2271 .get(&branch)
2272 .is_some_and(|record| record.kind == "pr");
2273 let preserved = ahead == 0
2274 || if recorded_pr {
2275 self.local_branch_is_preserved(&branch)?
2276 } else {
2277 self.pull_request_holds(&branch, &local_ref, base)
2278 };
2279 if !preserved {
2280 let listed = self
2281 .commit_lines(&self.root, &local_ref, base)
2282 .iter()
2283 .map(|line| format!(" {line}"))
2284 .collect::<Vec<_>>()
2285 .join("\n");
2286 bail!(
2287 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
2288 no pull request preserves them. Rebuilding it would delete the only copy.\n\
2289 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
2290 it, or delete it with `git branch -D {branch}` if it is stale."
2291 );
2292 }
2293 }
2294 Ok(())
2295 }
2296
2297 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2298 let live_ref = format!("refs/heads/{branch}");
2299 let tracking_ref = format!("refs/remotes/origin/{branch}");
2300 let listed = self
2301 .git(&["ls-remote", "--heads", "origin", &live_ref])
2302 .map_err(|e| {
2303 spar_err!(
2304 "could not verify whether origin/{branch} still exists: {}",
2305 e.last_line()
2306 )
2307 })?;
2308
2309 if remote_head_oid(&listed, &live_ref)?.is_some() {
2310 let refspec = format!("+{live_ref}:{tracking_ref}");
2311 self.git(&["fetch", "--no-tags", "origin", &refspec])
2312 .map_err(|e| {
2313 spar_err!(
2314 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2315 e.last_line()
2316 )
2317 })?;
2318 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2319 bail!("origin/{branch} was fetched but its tracking ref is missing");
2320 }
2321 return Ok(Some(tracking_ref));
2322 }
2323
2324 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2325 return Ok(None);
2326 }
2327 let expected = self
2328 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2329 .trim()
2330 .to_string();
2331 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2332 .map_err(|e| {
2333 spar_err!(
2334 "could not discard stale origin/{branch} tracking ref safely: {}",
2335 e.last_line()
2336 )
2337 })?;
2338 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2339 bail!(
2340 "origin/{branch} changed while its stale tracking ref was being removed. It was \
2341 kept."
2342 );
2343 }
2344 Ok(None)
2345 }
2346
2347 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2356 self.prs_for_branch(branch)
2357 .iter()
2358 .any(|pr| self.pr_head_holds(pr.number, refname, base))
2359 }
2360
2361 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2362 let head = format!("refs/spar/pr-head/{number}");
2363 let refspec = format!("+refs/pull/{number}/head:{head}");
2364 if self.git(&["fetch", "origin", &refspec]).is_err() {
2365 return false;
2366 }
2367 let held = self.commits_held_by(refname, base, &head);
2368 self.git_try(&["update-ref", "-d", &head]);
2369 held
2370 }
2371
2372 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2373 let argv = vec![
2374 "git".to_string(),
2375 "merge-base".to_string(),
2376 "--is-ancestor".to_string(),
2377 older.to_string(),
2378 newer.to_string(),
2379 ];
2380 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2381 match out.code {
2382 0 => Ok(true),
2383 1 => Ok(false),
2384 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2385 }
2386 }
2387
2388 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2389 let head = format!("refs/spar/pr-head/{number}");
2390 let refspec = format!("+refs/pull/{number}/head:{head}");
2391 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2392 spar_err!(
2393 "could not verify the immutable head of PR #{number}: {}",
2394 e.last_line()
2395 )
2396 })?;
2397 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2398 self.git_try(&["update-ref", "-d", &head]);
2399 held
2400 }
2401
2402 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2403 let text = self.gh(&[
2404 "pr",
2405 "list",
2406 "--head",
2407 branch,
2408 "--state",
2409 "all",
2410 "--json",
2411 "number,url,title",
2412 ])?;
2413 serde_json::from_str(text.trim())
2414 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2415 }
2416
2417 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2418 let branch_ref = format!("refs/heads/{branch}");
2419 if record.kind == "pr" {
2420 return self.pr_head_contains_checked(record.number, &branch_ref);
2421 }
2422 let prs = self.branch_prs_checked(branch)?;
2423 if prs.is_empty() {
2424 return Ok(false);
2425 }
2426 for pr in prs {
2427 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2428 return Ok(true);
2429 }
2430 }
2431 Ok(false)
2432 }
2433
2434 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2435 let local_ref = format!("refs/heads/{branch}");
2436 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2437 return Ok(true);
2438 }
2439 let oid = self
2440 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2441 .trim()
2442 .to_string();
2443 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2444 if !durable_tip {
2445 let remote_ref = format!("refs/heads/{branch}");
2446 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2447 durable_tip = remote.lines().any(|line| {
2448 line.split_whitespace()
2449 .next()
2450 .is_some_and(|remote_oid| remote_oid == oid)
2451 });
2452 }
2453 if !durable_tip {
2454 if let Some(record) = self.known_branches().get(branch) {
2455 durable_tip = self.branch_is_preserved_checked(branch, record)?;
2456 }
2457 }
2458 if !durable_tip {
2459 return Ok(false);
2460 }
2461 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2462 }
2463
2464 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2468 let local_ref = format!("refs/heads/{branch}");
2469 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2470 return Ok(true);
2471 }
2472 let expected = self
2473 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2474 .trim()
2475 .to_string();
2476 if !self.branch_deletion_is_safe(branch)? {
2477 return Ok(false);
2478 }
2479 let checked_out = self
2480 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2481 .lines()
2482 .any(|line| line == format!("branch {local_ref}"));
2483 if checked_out {
2484 return Ok(false);
2485 }
2486 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2487 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2488 }
2489
2490 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2491 let local_ref = review_ref(number);
2492 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2493 return Ok(true);
2494 }
2495 let oid = self
2496 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2497 .trim()
2498 .to_string();
2499 if !self.pr_head_contains_checked(number, &local_ref)? {
2500 return Ok(false);
2501 }
2502 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2503 }
2504
2505 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2506 let local_ref = review_ref(number);
2507 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2508 return Ok(true);
2509 }
2510 let expected = self
2511 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2512 .trim()
2513 .to_string();
2514 if !self.review_ref_deletion_is_safe(number)? {
2515 return Ok(false);
2516 }
2517 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2518 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2519 }
2520
2521 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2525 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2526 self.git_try(&["rev-list", "--count", &range, "--not", other])
2527 .trim()
2528 == "0"
2529 }
2530
2531 pub fn worktree_remove(&self, issue: i64) -> bool {
2532 let path = self.worktree_path(&format!("issue-{issue}"));
2533 match self.remove_worktree_at(&path) {
2534 Ok(removed) => removed,
2535 Err(error) => {
2536 logdim!(
2537 "kept {} because removal did not reach a confirmed quiet point: {}",
2538 path.display(),
2539 error.last_line()
2540 );
2541 false
2542 }
2543 }
2544 }
2545
2546 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2551 let wanted = std::fs::canonicalize(path)
2552 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2553 if wanted != path {
2558 return Ok(false);
2559 }
2560 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2561 let raw = PathBuf::from(value.trim());
2562 let joined = if raw.is_absolute() {
2563 raw
2564 } else {
2565 cwd.join(raw)
2566 };
2567 std::fs::canonicalize(&joined)
2568 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2569 };
2570 let expected =
2571 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2572 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2573 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2574 let expected = resolve(&self.root, &expected)?;
2575 let actual = resolve(path, &actual)?;
2576 let top = resolve(path, &top)?;
2577 Ok(expected == actual && top == wanted)
2578 }
2579
2580 fn remove_worktree_at_with_force(
2586 &self,
2587 path: &Path,
2588 force: bool,
2589 disposable: Disposable,
2590 ) -> Result<bool> {
2591 let existed = path.exists();
2592 if path.exists() {
2593 match self.worktree_belongs_to_repo(path) {
2594 Ok(true) => {}
2595 Ok(false) => {
2596 logdim!(
2597 "kept {} because it is not a worktree owned by this repository",
2598 path.display()
2599 );
2600 return Ok(false);
2601 }
2602 Err(e) => {
2603 logdim!(
2604 "kept {} because its worktree ownership could not be verified: {}",
2605 path.display(),
2606 e.last_line()
2607 );
2608 return Ok(false);
2609 }
2610 }
2611 if !force {
2612 match self.has_recoverable_work(path, disposable) {
2613 Ok(true) => {
2614 logdim!(
2615 "kept {} because it contains recoverable files or repository state",
2616 path.display()
2617 );
2618 return Ok(false);
2619 }
2620 Err(e) => {
2621 logdim!(
2622 "kept {} because its recoverable state could not be checked: {}",
2623 path.display(),
2624 e.last_line()
2625 );
2626 return Ok(false);
2627 }
2628 Ok(false) => {}
2629 }
2630 }
2631 }
2632 let path_str = path.display().to_string();
2638 let command_ok = if force || disposable == Disposable::ReviewOutput {
2639 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2640 } else {
2641 self.git_try_without_automation(&["worktree", "remove", &path_str])?
2642 };
2643 Ok((command_ok || !existed) && !path.exists())
2644 }
2645
2646 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2647 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2648 }
2649
2650 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2652 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2653 }
2654
2655 fn remove_worktree_at_force(&self, path: &Path) -> bool {
2657 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2658 Ok(removed) => removed,
2659 Err(error) => {
2660 logdim!(
2661 "kept {} because removal did not reach a confirmed quiet point: {}",
2662 path.display(),
2663 error.last_line()
2664 );
2665 false
2666 }
2667 }
2668 }
2669
2670 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2675 if path.exists() && !self.worktree_belongs_to_repo(path)? {
2676 bail!(
2677 "{} is not a worktree owned by this repository, so it was kept",
2678 path.display()
2679 );
2680 }
2681 if path.exists() && self.has_recoverable_work(path, disposable)? {
2682 bail!(
2683 "the verified worktree at {} contains recoverable files or repository state. It \
2684 was kept.",
2685 path.display()
2686 );
2687 }
2688 let path_str = path.display().to_string();
2689 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2690 .map_err(|e| {
2691 e.with_message(format!(
2692 "could not remove the verified worktree at {}: {}. It was kept.",
2693 path.display(),
2694 e.last_line()
2695 ))
2696 })?;
2697 Ok(!path.exists())
2698 }
2699
2700 fn refuse_dirty_worktree(
2701 &self,
2702 path: &Path,
2703 label: &str,
2704 disposable: Disposable,
2705 ) -> Result<()> {
2706 if !path.is_dir() {
2707 return Ok(());
2708 }
2709 let has_files = std::fs::read_dir(path)
2710 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2711 .next()
2712 .is_some();
2713 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2714 spar_err!(
2715 "could not verify whether the existing {label} at {} belongs to this repository, \
2716 so it was kept: {}",
2717 path.display(),
2718 e.last_line()
2719 )
2720 })?;
2721 if !owned {
2722 if has_files {
2723 bail!(
2724 "the existing {label} at {} is not a worktree owned by \
2725 this repository. Refusing to remove it.",
2726 path.display()
2727 );
2728 }
2729 return Ok(());
2730 }
2731 if !path.join(".git").exists() {
2732 if has_files {
2733 bail!(
2734 "the existing {label} at {} is not a readable Git worktree and is not empty. \
2735 Refusing to remove it.",
2736 path.display()
2737 );
2738 }
2739 return Ok(());
2740 }
2741 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2742 spar_err!(
2743 "could not verify whether the existing {label} at {} is clean, so it was kept: \
2744 {}",
2745 path.display(),
2746 e.last_line()
2747 )
2748 })?;
2749 if dirty {
2750 bail!(
2751 "the existing {label} contains uncommitted changes or ignored files at {}. \
2752 Rebuilding it would delete those files.\nCommit or recover them before running this \
2753 command again, or use `spar clean --all` if they are not needed.",
2754 path.display()
2755 );
2756 }
2757 Ok(())
2758 }
2759
2760 pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2763 let head = pr.head_ref_name.clone();
2764 if head.trim().is_empty() {
2765 bail!("PR #{} has no head branch to check out", pr.number);
2766 }
2767 let path = self.worktree_path(&format!("pr-{}", pr.number));
2768 let local = self.branch_for_pr(pr.number);
2769
2770 let unfetched = |e: crate::error::SparError| {
2771 spar_err!(
2772 "could not fetch the branch behind PR #{}: {}",
2773 pr.number,
2774 e.last_line()
2775 )
2776 };
2777 let (start, start_ref, shown) = match target {
2780 PushTarget::Origin => {
2781 self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2782 let start = format!("origin/{head}");
2783 (start.clone(), format!("refs/remotes/origin/{head}"), start)
2784 }
2785 PushTarget::Fork(fork) => {
2788 let start_ref = format!("refs/spar/fork/{head}");
2789 let refspec = format!("+refs/heads/{head}:{start_ref}");
2790 self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2791 .map_err(unfetched)?;
2792 (start_ref.clone(), start_ref, target.describe(&head))
2793 }
2794 };
2795 let local_ref = format!("refs/heads/{local}");
2796 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2797 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2798 if unpushed > 0 {
2799 bail!(
2800 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2801 on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2802 {} and push or recover those commits before running this command again.",
2803 pr.number,
2804 path.display()
2805 );
2806 }
2807 }
2808 self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2809 self.refuse_dirty_worktree(
2810 &path,
2811 &format!("worktree for PR #{}", pr.number),
2812 Disposable::BuildOutput,
2813 )?;
2814 if !self.branch_deletion_is_safe(&local)? {
2815 bail!(
2816 "the existing branch {local} has a tip or reflog-only commit that no surviving \
2817 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2818 before retrying."
2819 );
2820 }
2821 if !self.remove_worktree_at(&path)? {
2822 bail!(
2823 "the existing worktree for PR #{} could not be removed safely. Its branch was \
2824 kept.",
2825 pr.number
2826 );
2827 }
2828 if !self.delete_branch_if_safe(&local)? {
2829 bail!(
2830 "the existing branch {local} changed or remained checked out while the PR \
2831 worktree was being rebuilt. It was kept."
2832 );
2833 }
2834
2835 let path_str = path.display().to_string();
2836 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2837 self.record_branch(&local, "pr", pr.number);
2838 self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2839 Ok((path, head))
2840 }
2841
2842 fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2845 if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2846 return Ok(());
2847 }
2848 let current =
2849 self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2850 if current.trim() != branch
2851 || self.has_uncommitted_changes(path)?
2852 || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2853 {
2854 return Ok(());
2855 }
2856 static NEXT: AtomicUsize = AtomicUsize::new(0);
2857 let (name, retained) = loop {
2858 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2859 let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2860 let retained = self.worktree_path(&name);
2861 if !retained
2862 .try_exists()
2863 .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2864 && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2865 {
2866 break (name, retained);
2867 }
2868 };
2869 self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2872 if let Err(error) = self.git_at_without_automation(
2873 &self.root,
2874 &[
2875 "worktree",
2876 "move",
2877 &path.display().to_string(),
2878 &retained.display().to_string(),
2879 ],
2880 ) {
2881 let rollback =
2884 self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2885 let detail = rollback
2886 .err()
2887 .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2888 .unwrap_or_default();
2889 bail!(
2890 "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2891 retained.display(),
2892 error.last_line()
2893 );
2894 }
2895 logdim!(
2896 "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2897 retained.display()
2898 );
2899 Ok(())
2900 }
2901
2902 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2912 let path = self.worktree_path(&format!("review-{number}"));
2913 let local_ref = review_ref(number);
2914 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2915
2916 self.refuse_review_worktree_changes(number)?;
2917
2918 if let Some(parent) = path.parent() {
2919 std::fs::create_dir_all(parent)
2920 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2921 }
2922 if !self.remove_review_worktree_at(&path)? {
2927 bail!(
2928 "the existing review worktree for PR #{number} could not be removed safely. Its \
2929 reference was kept."
2930 );
2931 }
2932
2933 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2934 spar_err!(
2935 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2936 every pull request, so this usually means the number is wrong or `origin` does \
2937 not point at the repository the PR is on.",
2938 e.last_line()
2939 )
2940 })?;
2941
2942 let path_str = path.display().to_string();
2943 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2944 self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2945 Ok(path)
2946 }
2947
2948 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2949 let path = self.worktree_path(&format!("review-{number}"));
2950 if !path.is_dir() {
2951 return Ok(());
2952 }
2953 let local_ref = review_ref(number);
2954 if !self.worktree_belongs_to_repo(&path)? {
2955 return Ok(());
2956 }
2957 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2958 bail!(
2959 "the existing review worktree for PR #{number} has no recorded head at \
2960 {local_ref}. Refusing to rebuild {}.",
2961 path.display()
2962 );
2963 }
2964 let worktree_head = self.head_oid_checked(&path)?;
2965 let recorded_head = self
2966 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2967 .trim()
2968 .to_string();
2969 if worktree_head != recorded_head {
2970 bail!(
2971 "the existing review worktree for PR #{number} has a local commit that is not on \
2972 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2973 {} before retrying.",
2974 path.display()
2975 );
2976 }
2977 self.report_recovery_markers(&path);
2978 self.refuse_dirty_worktree(
2979 &path,
2980 &format!("review worktree for PR #{number}"),
2981 Disposable::ReviewOutput,
2982 )?;
2983 Ok(())
2984 }
2985
2986 fn report_recovery_markers(&self, path: &Path) {
2994 let Ok(entries) = std::fs::read_dir(path) else {
2995 return;
2996 };
2997 for entry in entries.flatten() {
2998 let name = entry.file_name();
2999 let Some(name) = name.to_str() else { continue };
3000 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
3001 continue;
3002 }
3003 match std::fs::read_to_string(entry.path()) {
3004 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
3005 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
3006 }
3007 }
3008 }
3009
3010 pub fn read_checkout(&self, label: &str) -> Result<ReadCheckout<'_>> {
3016 let shared = || ReadCheckout {
3017 repo: self,
3018 path: self.root.clone(),
3019 checkpoint: None,
3020 };
3021 if !self.isolate_reads {
3022 return Ok(shared());
3023 }
3024 let Some(head) = resolve_optional_commit(&self.root, "HEAD")? else {
3025 return Ok(shared());
3026 };
3027 static NEXT: AtomicUsize = AtomicUsize::new(0);
3028 let path = loop {
3029 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
3030 let path = self.worktree_path(&format!(
3031 "{READ_CHECKOUT_PREFIX}{}-{serial}",
3032 std::process::id()
3033 ));
3034 if !path
3035 .try_exists()
3036 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
3037 {
3038 break path;
3039 }
3040 };
3041 if let Some(parent) = path.parent() {
3042 std::fs::create_dir_all(parent)
3043 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
3044 }
3045 let path_str = path.display().to_string();
3046 self.git(&["worktree", "add", "--detach", &path_str, &head])
3047 .map_err(|e| {
3048 spar_err!(
3049 "could not check HEAD out for the {label}: {}",
3050 e.last_line()
3051 )
3052 })?;
3053 let ready = self
3054 .set_up_worktree(&path, &format!("read-only checkout for the {label}"))
3055 .and_then(|()| self.worktree_checkpoint(&path));
3056 match ready {
3057 Ok(checkpoint) => Ok(ReadCheckout {
3058 repo: self,
3059 path,
3060 checkpoint: Some(checkpoint),
3061 }),
3062 Err(e) => {
3063 self.discard_read_checkout(&path);
3065 Err(e)
3066 }
3067 }
3068 }
3069
3070 fn release_read_checkout(&self, path: &Path, checkpoint: &WorktreeCheckpoint) {
3077 if self.keep_worktrees {
3078 logdim!("kept the read-only checkout at {}", path.display());
3079 return;
3080 }
3081 if has_recovery_marker(path) {
3082 logdim!(
3083 "kept the read-only checkout at {} for recovery",
3084 path.display()
3085 );
3086 return;
3087 }
3088 if let Err(e) = self.require_unchanged_worktree(path, checkpoint, "read-only checkout") {
3089 logwarn!("{e}");
3090 return;
3091 }
3092 self.discard_read_checkout(path);
3093 }
3094
3095 fn discard_read_checkout(&self, path: &Path) {
3096 match self.remove_worktree_at_checked(path, Disposable::ReviewOutput) {
3097 Ok(true) => {}
3098 Ok(false) => logdim!(
3099 "kept the read-only checkout at {} because it was still there after removal",
3100 path.display()
3101 ),
3102 Err(e) => logdim!(
3103 "kept the read-only checkout at {}: {}",
3104 path.display(),
3105 e.last_line()
3106 ),
3107 }
3108 }
3109
3110 pub fn worktree_for_split(
3123 &self,
3124 parent: i64,
3125 index: usize,
3126 start: &str,
3127 ) -> Result<(PathBuf, String)> {
3128 let slot = self.free_split_slot(parent, index)?;
3129 let branch = format!("{}{slot}", self.branch_prefix);
3130 let path = self.worktree_path(&slot);
3131
3132 if let Some(dir) = path.parent() {
3133 std::fs::create_dir_all(dir)
3134 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
3135 }
3136 self.refuse_dirty_worktree(
3137 &path,
3138 &format!("worktree for part {index} of PR #{parent}"),
3139 Disposable::BuildOutput,
3140 )?;
3141 if !self.remove_worktree_at(&path)? {
3145 bail!(
3146 "the existing worktree for part {index} of PR #{parent} could not be removed \
3147 safely. No branch was created."
3148 );
3149 }
3150
3151 let path_str = path.display().to_string();
3152 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
3153 .map_err(|e| {
3154 spar_err!(
3155 "could not create a worktree for part {index} of #{parent}. {}",
3156 e.last_line()
3157 )
3158 })?;
3159 self.record_branch(&branch, "split", parent);
3162 self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
3163 Ok((path, branch))
3164 }
3165
3166 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
3173 for attempt in 1..=SPLIT_SLOTS {
3174 let slot = split_slot(parent, index, attempt);
3175 let branch = format!("{}{slot}", self.branch_prefix);
3176 self.git_try(&["fetch", "origin", &branch]);
3177 if !self.rev_exists(&self.root, &branch)
3178 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
3179 {
3180 return Ok(slot);
3181 }
3182 }
3183 bail!(
3184 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
3185 names are all taken. Inspect the existing branches and child pull requests. Finish \
3186 recording the earlier split, or remove every retained local worktree and branch, \
3187 child pull request, and remote split branch before starting over.",
3188 self.branch_for_split(parent, index)
3189 )
3190 }
3191
3192 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
3198 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
3199 Ok(!self
3200 .git(&["ls-remote", "--heads", "origin", &pattern])?
3201 .trim()
3202 .is_empty())
3203 }
3204
3205 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
3212 match self.branch_deletion_is_safe(branch) {
3213 Ok(true) => {}
3214 Ok(false) => {
3215 logdim!(
3216 "kept {branch} and {} because no surviving ref preserves its tip",
3217 dir.display()
3218 );
3219 return;
3220 }
3221 Err(error) => {
3222 logdim!(
3223 "kept {branch} and {} because preservation could not be verified: {}",
3224 dir.display(),
3225 error.last_line()
3226 );
3227 return;
3228 }
3229 }
3230 match self.remove_worktree_at(dir) {
3231 Ok(true) => match self.delete_branch_if_safe(branch) {
3232 Ok(true) => self.forget_branch(branch),
3233 Ok(false) => {
3234 logdim!("kept {branch} because its tip or reflog changed before deletion")
3235 }
3236 Err(error) => logdim!(
3237 "kept {branch} because deletion safety could not be rechecked: {}",
3238 error.last_line()
3239 ),
3240 },
3241 Ok(false) => {}
3242 Err(error) => logdim!(
3243 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
3244 dir.display(),
3245 error.last_line()
3246 ),
3247 }
3248 }
3249
3250 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
3256 let record = self.known_branches().get(branch).cloned();
3257 if record.is_none_or(|record| record.kind != "split") {
3258 logdim!("kept {branch} because no split branch record proves ownership");
3259 return false;
3260 }
3261 let local_ref = format!("refs/heads/{branch}");
3262 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
3263 Ok(value) => value.trim().to_string(),
3264 Err(error) => {
3265 logdim!(
3266 "kept {branch} because its tip could not be checked: {}",
3267 error.last_line()
3268 );
3269 return false;
3270 }
3271 };
3272 if expected != disposable_head {
3273 logdim!("kept {branch} because it moved beyond the disposable slice");
3274 return false;
3275 }
3276 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
3277 Ok(true) => {}
3278 Ok(false) => {
3279 logdim!(
3280 "kept {branch} because its reflog contains work outside the disposable slice"
3281 );
3282 return false;
3283 }
3284 Err(error) => {
3285 logdim!(
3286 "kept {branch} because its reflog could not be checked: {}",
3287 error.last_line()
3288 );
3289 return false;
3290 }
3291 }
3292 match self.head_oid_checked(dir) {
3293 Ok(head) if head == disposable_head => {}
3294 Ok(_) => {
3295 logdim!(
3296 "kept {branch} and {} because the worktree moved beyond the disposable slice",
3297 dir.display()
3298 );
3299 return false;
3300 }
3301 Err(error) => {
3302 logdim!(
3303 "kept {branch} and {} because its head could not be checked: {}",
3304 dir.display(),
3305 error.last_line()
3306 );
3307 return false;
3308 }
3309 }
3310 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
3311 Ok(true) => {}
3312 Ok(false) => return false,
3313 Err(error) => {
3314 logdim!(
3315 "kept {branch} and {} because the disposable slice could not be verified: {}",
3316 dir.display(),
3317 error.last_line()
3318 );
3319 return false;
3320 }
3321 }
3322 if let Err(error) =
3323 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
3324 {
3325 logdim!(
3326 "kept {branch} because its exact disposable tip could not be deleted: {}",
3327 error.last_line()
3328 );
3329 return false;
3330 }
3331 match self.exact_ref_exists_checked(&self.root, &local_ref) {
3332 Ok(false) => {
3333 self.forget_branch(branch);
3334 true
3335 }
3336 Ok(true) => {
3337 logdim!("kept {branch} because its ref still exists after deletion");
3338 false
3339 }
3340 Err(error) => {
3341 logdim!(
3342 "kept the branch record for {branch} because deletion could not be verified: {}",
3343 error.last_line()
3344 );
3345 false
3346 }
3347 }
3348 }
3349
3350 pub fn release_review_worktree(&self, number: i64) {
3351 let path = self.worktree_path(&format!("review-{number}"));
3352 match self.review_ref_deletion_is_safe(number) {
3353 Ok(true) => {}
3354 Ok(false) => {
3355 logdim!(
3356 "kept {} because no surviving ref preserves its review history",
3357 path.display()
3358 );
3359 return;
3360 }
3361 Err(error) => {
3362 logdim!(
3363 "kept {} because review history could not be verified: {}",
3364 path.display(),
3365 error.last_line()
3366 );
3367 return;
3368 }
3369 }
3370 match self.remove_worktree_at(&path) {
3371 Ok(true) => match self.delete_review_ref_if_safe(number) {
3372 Ok(true) => {}
3373 Ok(false) => logdim!(
3374 "kept {} because its review history changed before deletion",
3375 review_ref(number)
3376 ),
3377 Err(error) => logdim!(
3378 "kept {} because deletion safety could not be rechecked: {}",
3379 review_ref(number),
3380 error.last_line()
3381 ),
3382 },
3383 Ok(false) => {}
3384 Err(error) => logdim!(
3385 "kept {} because removal did not reach a confirmed quiet point: {}",
3386 path.display(),
3387 error.last_line()
3388 ),
3389 }
3390 }
3391
3392 pub(crate) fn release_review_worktree_checked(
3395 &self,
3396 number: i64,
3397 checkpoint: &WorktreeCheckpoint,
3398 ) -> Result<()> {
3399 let path = self.worktree_path(&format!("review-{number}"));
3400 self.require_unchanged_worktree(
3401 &path,
3402 checkpoint,
3403 &format!("review worktree for PR #{number}"),
3404 )?;
3405 if !self.review_ref_deletion_is_safe(number)? {
3406 bail!(
3407 "the review reference for PR #{number} has reflog-only recovery history. The \
3408 worktree and reference were kept."
3409 );
3410 }
3411 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3412 bail!(
3413 "the verified review worktree at {} could not be removed, so its reference was \
3414 kept",
3415 path.display()
3416 );
3417 }
3418 if !self.delete_review_ref_if_safe(number)? {
3419 bail!(
3420 "the review reference for PR #{number} changed before deletion. The reference was \
3421 kept."
3422 );
3423 }
3424 Ok(())
3425 }
3426
3427 pub fn release_pr_worktree(&self, number: i64) -> bool {
3428 let path = self.worktree_path(&format!("pr-{number}"));
3429 let local = self.branch_for_pr(number);
3430 match self.branch_deletion_is_safe(&local) {
3431 Ok(true) => {}
3432 Ok(false) => {
3433 logdim!(
3434 "kept {local} and {} because no surviving ref preserves its tip",
3435 path.display()
3436 );
3437 return false;
3438 }
3439 Err(error) => {
3440 logdim!(
3441 "kept {local} and {} because preservation could not be verified: {}",
3442 path.display(),
3443 error.last_line()
3444 );
3445 return false;
3446 }
3447 }
3448 match self.remove_worktree_at(&path) {
3449 Ok(true) => match self.delete_branch_if_safe(&local) {
3450 Ok(true) => {
3451 self.forget_branch(&local);
3452 true
3453 }
3454 Ok(false) => {
3455 logdim!("kept {local} because its tip or reflog changed before deletion");
3456 false
3457 }
3458 Err(error) => {
3459 logdim!(
3460 "kept {local} because deletion safety could not be rechecked: {}",
3461 error.last_line()
3462 );
3463 false
3464 }
3465 },
3466 Ok(false) => false,
3467 Err(error) => {
3468 logdim!(
3469 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3470 path.display(),
3471 error.last_line()
3472 );
3473 false
3474 }
3475 }
3476 }
3477
3478 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3489 let remote = format!("origin/{base}");
3490 if self.rev_exists(cwd, &remote) {
3491 return remote;
3492 }
3493 if self.rev_exists(cwd, base) {
3494 logdim!("origin/{base} does not resolve, comparing against local {base}");
3495 return base.to_string();
3496 }
3497 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3498 remote
3499 }
3500
3501 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3502 let spec = format!("{refname}^{{commit}}");
3503 !self
3504 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3505 .trim()
3506 .is_empty()
3507 }
3508
3509 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3510 let range = format!("{}..HEAD", self.base_ref(cwd, base));
3511 !self
3512 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3513 .trim()
3514 .is_empty()
3515 }
3516
3517 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3518 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3519 Ok(found.lines().any(|line| line.trim() == refname))
3520 }
3521
3522 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3523 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3524 count.trim().parse::<usize>().map_err(|e| {
3525 spar_err!(
3526 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3527 count.trim()
3528 )
3529 })
3530 }
3531
3532 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3533 let remote = format!("refs/remotes/origin/{base}");
3534 if self.exact_ref_exists_checked(cwd, &remote)? {
3535 return Ok(remote);
3536 }
3537 let local = format!("refs/heads/{base}");
3538 if self.exact_ref_exists_checked(cwd, &local)? {
3539 return Ok(local);
3540 }
3541 bail!("neither origin/{base} nor local branch {base} resolves")
3542 }
3543
3544 pub(crate) fn commit_count_checked(
3545 &self,
3546 cwd: &Path,
3547 refname: &str,
3548 base: &str,
3549 ) -> Result<usize> {
3550 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3551 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3552 count.trim().parse::<usize>().map_err(|e| {
3553 spar_err!(
3554 "git returned an invalid commit count for {range}: {:?} ({e})",
3555 count.trim()
3556 )
3557 })
3558 }
3559
3560 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3561 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3562 }
3563
3564 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3565 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3566 let head = head.trim().to_string();
3567 if head.is_empty() {
3568 bail!("git returned an empty HEAD for {}", cwd.display());
3569 }
3570 Ok(head)
3571 }
3572
3573 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3578 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3579 self.local_branch_is_preserved(branch.trim())
3580 }
3581
3582 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3585 let known = self.known_branches();
3586 let Some(record) = known.get(branch) else {
3587 return Ok(false);
3588 };
3589 self.branch_is_preserved_checked(branch, record)
3590 }
3591
3592 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3594 has_uncommitted_work(cwd)
3595 }
3596
3597 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3600 repository_has_recoverable_work_with(cwd, true, disposable)
3601 }
3602
3603 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3605 let attributes = attribute_state(cwd)?;
3606 Ok(WorktreeBaseline {
3607 attributes,
3608 ignored_untracked: ignored_untracked_state(cwd)?,
3609 git_state: safe_git_state(cwd)?,
3610 })
3611 }
3612
3613 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3616 let attributes = attribute_state(cwd)?;
3617 Ok(WorktreeCheckpoint {
3618 path: std::fs::canonicalize(cwd)
3619 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3620 attributes,
3621 git_state: safe_git_state(cwd)?,
3622 ignored_untracked: ignored_untracked_state(cwd)?,
3623 })
3624 }
3625
3626 pub(crate) fn set_aside_review_scratch(
3642 &self,
3643 cwd: &Path,
3644 checkpoint: &WorktreeCheckpoint,
3645 number: i64,
3646 ) {
3647 let Ok(after) = probe_again_on_failure(|| ignored_untracked_state(cwd)) else {
3648 return;
3649 };
3650 let created: Vec<PathBuf> = checkpoint
3651 .ignored_untracked
3652 .new_ordinary_paths(&after)
3653 .into_iter()
3654 .filter(|path| {
3655 std::fs::symlink_metadata(cwd.join(path))
3656 .is_ok_and(|meta| meta.is_file() || meta.file_type().is_symlink())
3657 })
3658 .collect();
3659 if created.is_empty() {
3660 return;
3661 }
3662 let shelf = match self.scratch_shelf(number) {
3663 Ok(shelf) => shelf,
3664 Err(e) => {
3665 logdim!("could not make room to move a reviewer's files aside: {e}");
3666 return;
3667 }
3668 };
3669 let mut moved = Vec::new();
3670 for path in created {
3671 let to = shelf.join(&path);
3672 let result = to
3673 .parent()
3674 .map_or(Ok(()), std::fs::create_dir_all)
3675 .and_then(|()| std::fs::rename(cwd.join(&path), &to));
3676 match result {
3677 Ok(()) => moved.push(path),
3678 Err(e) => logdim!(
3679 "could not move {} out of the review checkout: {e}",
3680 cwd.join(&path).display()
3681 ),
3682 }
3683 }
3684 if !moved.is_empty() {
3685 logwarn!(
3686 "PR #{number}: a reviewer left {} file(s) in the review checkout: {}. They were \
3687 moved to {} and the review went on.",
3688 moved.len(),
3689 list_paths(&moved),
3690 shelf.display()
3691 );
3692 }
3693 }
3694
3695 fn scratch_shelf(&self, number: i64) -> Result<PathBuf> {
3698 let scratch = self.root.join(STATE_DIR).join("scratch");
3699 std::fs::create_dir_all(&scratch)
3700 .map_err(|e| spar_err!("could not create {}: {e}", scratch.display()))?;
3701 let stem = format!("pr-{number}-{}", crate::clock::now_secs());
3702 for serial in 0..1000 {
3703 let name = match serial {
3704 0 => stem.clone(),
3705 n => format!("{stem}-{n}"),
3706 };
3707 let shelf = scratch.join(name);
3708 match std::fs::create_dir(&shelf) {
3709 Ok(()) => return Ok(shelf),
3710 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
3711 Err(e) => return Err(spar_err!("could not create {}: {e}", shelf.display())),
3712 }
3713 }
3714 bail!(
3715 "could not choose a free directory under {}",
3716 scratch.display()
3717 )
3718 }
3719
3720 pub(crate) fn require_unchanged_worktree(
3723 &self,
3724 cwd: &Path,
3725 checkpoint: &WorktreeCheckpoint,
3726 label: &str,
3727 ) -> Result<()> {
3728 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3729 crate::error::SparError::uncertain_write(format!(
3730 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3731 cwd.display()
3732 ))
3733 })?;
3734 if resolved != checkpoint.path {
3735 return Err(uncertain_worktree_change(
3736 cwd,
3737 format!(
3738 "the {label} moved from {} to {} during inspection. It was kept.",
3739 checkpoint.path.display(),
3740 resolved.display()
3741 ),
3742 ));
3743 }
3744 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3745 uncertain_worktree_change(
3746 cwd,
3747 format!(
3748 "could not verify attribute files in the {label} at {}: {}. It was kept.",
3749 cwd.display(),
3750 e.last_line()
3751 ),
3752 )
3753 })?;
3754 if attributes != checkpoint.attributes {
3755 return Err(uncertain_worktree_change(
3756 cwd,
3757 format!(
3758 "attribute files in the {label} at {} changed during inspection. It was \
3759 kept for recovery.",
3760 cwd.display()
3761 ),
3762 ));
3763 }
3764 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3765 uncertain_worktree_change(
3766 cwd,
3767 format!(
3768 "could not verify the Git state of the {label} at {}: {}. It was kept.",
3769 cwd.display(),
3770 e.last_line()
3771 ),
3772 )
3773 })?;
3774 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3775 uncertain_worktree_change(
3776 cwd,
3777 format!(
3778 "could not verify untracked files in the {label} at {}: {}. It was kept.",
3779 cwd.display(),
3780 e.last_line()
3781 ),
3782 )
3783 })?;
3784 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3785 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3789 let tracked = checkpoint.git_state.describe_difference(&git_state);
3790 let untracked = (!leavings.condemning.is_empty()).then(|| {
3791 format!(
3792 "untracked file(s) changed: {}",
3793 list_paths(&leavings.condemning)
3794 )
3795 });
3796 let Some(reason) = join_reasons(tracked, untracked) else {
3797 return Ok(());
3798 };
3799 Err(uncertain_worktree_change(
3800 cwd,
3801 format!(
3802 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3803 for recovery.",
3804 cwd.display()
3805 ),
3806 ))
3807 }
3808
3809 pub(crate) fn refuse_new_ignored_files(
3813 &self,
3814 cwd: &Path,
3815 baseline: &WorktreeBaseline,
3816 ) -> Result<()> {
3817 self.allow_ignored_files(cwd, baseline).map(drop)
3818 }
3819
3820 pub(crate) fn refuse_failed_edit_leavings(
3822 &self,
3823 cwd: &Path,
3824 baseline: &WorktreeBaseline,
3825 ) -> Result<()> {
3826 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3827 .map(drop)
3828 }
3829
3830 pub(crate) fn refuse_read_only_leavings(
3832 &self,
3833 cwd: &Path,
3834 baseline: &WorktreeBaseline,
3835 ) -> Result<()> {
3836 self.refuse_changed_attributes(cwd, baseline)?;
3837 let after = ignored_untracked_state(cwd).map_err(|e| {
3838 uncertain_worktree_change(
3839 cwd,
3840 format!(
3841 "could not verify untracked files in {} after a read-only call: {}. The \
3842 worktree was kept for recovery.",
3843 cwd.display(),
3844 e.last_line()
3845 ),
3846 )
3847 })?;
3848 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3849 if leavings.condemning.is_empty() {
3850 return Ok(());
3851 }
3852 Err(uncertain_worktree_change(
3853 cwd,
3854 format!(
3855 "the read-only call left untracked file(s) in {} that the project does not \
3856 ignore: {}. The worktree was kept for recovery.",
3857 cwd.display(),
3858 list_paths(&leavings.condemning)
3859 ),
3860 ))
3861 }
3862
3863 fn allow_ignored_files(
3866 &self,
3867 cwd: &Path,
3868 baseline: &WorktreeBaseline,
3869 ) -> Result<AllowedArtifacts> {
3870 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3871 }
3872
3873 fn check_new_ignored_files(
3874 &self,
3875 cwd: &Path,
3876 baseline: &WorktreeBaseline,
3877 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3878 ) -> Result<AllowedArtifacts> {
3879 self.refuse_changed_attributes(cwd, baseline)?;
3880 let after = ignored_untracked_state(cwd).map_err(|e| {
3881 uncertain_worktree_change(
3882 cwd,
3883 format!(
3884 "could not verify untracked files in {} after editing: {}. The worktree was \
3885 kept for recovery.",
3886 cwd.display(),
3887 e.last_line()
3888 ),
3889 )
3890 })?;
3891 let changed = baseline.ignored_untracked.changed_paths(&after);
3892 if changed.is_empty() {
3893 return Ok(AllowedArtifacts::default());
3894 }
3895 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3896 .into_iter()
3897 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3898 if changed.is_empty() {
3899 return Ok(AllowedArtifacts::split(ignored, &after));
3900 }
3901 let listed = list_paths(&changed);
3902 Err(uncertain_worktree_change(
3903 cwd,
3904 format!(
3905 "the editing call created or changed untracked or ignored file(s) in {} that \
3906 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3907 recovery.",
3908 cwd.display()
3909 ),
3910 ))
3911 }
3912
3913 pub(crate) fn refuse_changed_existing_untracked(
3918 &self,
3919 cwd: &Path,
3920 baseline: &WorktreeBaseline,
3921 ) -> Result<()> {
3922 self.check_changed_existing_untracked(cwd, baseline)
3923 .map(drop)
3924 }
3925
3926 fn allow_changed_ignored_artifacts(
3927 &self,
3928 cwd: &Path,
3929 baseline: &WorktreeBaseline,
3930 ) -> Result<AllowedArtifacts> {
3931 self.check_changed_existing_untracked(cwd, baseline)
3932 }
3933
3934 fn check_changed_existing_untracked(
3935 &self,
3936 cwd: &Path,
3937 baseline: &WorktreeBaseline,
3938 ) -> Result<AllowedArtifacts> {
3939 self.refuse_changed_attributes(cwd, baseline)?;
3940 let after = ignored_untracked_state(cwd).map_err(|e| {
3941 uncertain_worktree_change(
3942 cwd,
3943 format!(
3944 "could not verify existing untracked files in {} after editing: {}. The \
3945 worktree was kept for recovery.",
3946 cwd.display(),
3947 e.last_line()
3948 ),
3949 )
3950 })?;
3951 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3952 if changed.is_empty() {
3953 return Ok(AllowedArtifacts::default());
3954 }
3955 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3956 .into_iter()
3957 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3958 if changed.is_empty() {
3959 return Ok(AllowedArtifacts::split(ignored, &after));
3960 }
3961 let listed = list_paths(&changed);
3962 Err(uncertain_worktree_change(
3963 cwd,
3964 format!(
3965 "the editing call changed or deleted existing untracked file(s) in {}: \
3966 {listed}. The worktree was kept for recovery.",
3967 cwd.display()
3968 ),
3969 ))
3970 }
3971
3972 pub(crate) fn refuse_unrepresented_tracked_changes(
3979 &self,
3980 cwd: &Path,
3981 baseline: &WorktreeBaseline,
3982 ) -> Result<()> {
3983 self.refuse_changed_attributes(cwd, baseline)?;
3984 let after = safe_git_state(cwd).map_err(|e| {
3985 uncertain_worktree_change(
3986 cwd,
3987 format!(
3988 "could not verify tracked files in {} after editing: {}. The worktree was \
3989 kept for recovery.",
3990 cwd.display(),
3991 e.last_line()
3992 ),
3993 )
3994 })?;
3995 let mut changed = Vec::new();
3996 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3997 uncertain_worktree_change(
3998 cwd,
3999 format!(
4000 "could not record untracked files before verifying transformed content in {}: \
4001 {}. The worktree was kept for recovery.",
4002 cwd.display(),
4003 e.last_line()
4004 ),
4005 )
4006 })?;
4007 let mut filter_was_run = false;
4008 let mut filter_problem = None;
4009 let mut repositories: BTreeSet<PathBuf> =
4010 baseline.git_state.repositories.keys().cloned().collect();
4011 repositories.extend(after.repositories.keys().cloned());
4012 'repositories: for repository_path in repositories {
4013 let before_repository = baseline.git_state.repositories.get(&repository_path);
4014 let after_repository = after.repositories.get(&repository_path);
4015 if before_repository.is_none() || after_repository.is_none() {
4016 changed.push(repository_path.clone());
4017 continue;
4018 }
4019 if before_repository.map(|repository| &repository.gitlinks)
4020 != after_repository.map(|repository| &repository.gitlinks)
4021 {
4022 changed.push(repository_path.join("<gitlinks>"));
4023 }
4024 let mut paths = BTreeSet::new();
4025 if let Some(repository) = before_repository {
4026 paths.extend(repository.tracked.keys().cloned());
4027 }
4028 if let Some(repository) = after_repository {
4029 paths.extend(repository.tracked.keys().cloned());
4030 }
4031 for path in paths {
4032 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
4033 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
4034 let worktree_changed =
4035 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
4036 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
4037 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
4038 if !worktree_changed {
4039 continue;
4040 }
4041 if !index_changed {
4042 changed.push(repository_path.join(&path));
4043 continue;
4044 }
4045 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
4046 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
4047 let Some(current_entry) = current else {
4048 continue;
4049 };
4050 let Some(current_worktree) = current_worktree else {
4051 continue;
4052 };
4053 let content_changed =
4054 before_worktree.map(|file| file.content) != Some(current_worktree.content);
4055 let mode_changed = before_worktree.map(|file| file.mode.as_str())
4056 != Some(current_worktree.mode.as_str());
4057 let repository = cwd.join(&repository_path);
4058 let represented_content = if content_changed {
4059 filter_was_run = true;
4060 let result =
4061 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
4062 self.refuse_changed_attributes(cwd, baseline)?;
4063 match result {
4064 Ok(expected) => expected == current_worktree.content,
4065 Err(error) => {
4066 filter_problem = Some(format!(
4067 "could not verify transformed content for {:?}: {}",
4068 repository_path.join(&path),
4069 error.last_line()
4070 ));
4071 false
4072 }
4073 }
4074 } else {
4075 true
4076 };
4077 let represented_mode =
4078 !mode_changed || current_worktree.mode == current_entry.index_mode;
4079 if !represented_content || !represented_mode {
4080 changed.push(repository_path.join(&path));
4081 }
4082 if filter_problem.is_some() {
4083 break 'repositories;
4084 }
4085 }
4086 }
4087 if filter_was_run {
4088 self.refuse_changed_attributes(cwd, baseline)?;
4089 let verified = safe_git_state(cwd).map_err(|e| {
4090 uncertain_worktree_change(
4091 cwd,
4092 format!(
4093 "could not recheck tracked files after verifying transformed content in \
4094 {}: {}. The worktree was kept for recovery.",
4095 cwd.display(),
4096 e.last_line()
4097 ),
4098 )
4099 })?;
4100 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
4101 uncertain_worktree_change(
4102 cwd,
4103 format!(
4104 "could not recheck untracked files after verifying transformed content \
4105 in {}: {}. The worktree was kept for recovery.",
4106 cwd.display(),
4107 e.last_line()
4108 ),
4109 )
4110 })?;
4111 if verified != after
4112 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
4113 {
4114 return Err(uncertain_worktree_change(
4115 cwd,
4116 "a content filter changed the worktree while SPAR verified the managed \
4117 commit. The worktree was kept for recovery.",
4118 ));
4119 }
4120 self.refuse_changed_existing_untracked(cwd, baseline)?;
4121 }
4122 if let Some(problem) = filter_problem {
4123 return Err(uncertain_worktree_change(
4124 cwd,
4125 format!("{problem}. The worktree was kept for recovery."),
4126 ));
4127 }
4128 if changed.is_empty() {
4129 return Ok(());
4130 }
4131 let listed = list_paths(&changed);
4132 Err(uncertain_worktree_change(
4133 cwd,
4134 format!(
4135 "the editing call changed tracked working-file bytes, modes, repositories, or \
4136 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
4137 recovery."
4138 ),
4139 ))
4140 }
4141
4142 pub(crate) fn refuse_changed_attributes(
4143 &self,
4144 cwd: &Path,
4145 baseline: &WorktreeBaseline,
4146 ) -> Result<()> {
4147 let after = attribute_state(cwd).map_err(|e| {
4148 uncertain_worktree_change(
4149 cwd,
4150 format!(
4151 "could not verify attribute files in {} after editing: {}. The worktree was \
4152 kept for recovery.",
4153 cwd.display(),
4154 e.last_line()
4155 ),
4156 )
4157 })?;
4158 if after == baseline.attributes {
4159 return Ok(());
4160 }
4161 Err(uncertain_worktree_change(
4162 cwd,
4163 format!(
4164 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
4165 refused to run a Git operation that could select a new external filter.",
4166 cwd.display()
4167 ),
4168 ))
4169 }
4170
4171 pub(crate) fn commit_pending_changes(
4176 &self,
4177 cwd: &Path,
4178 baseline: &WorktreeBaseline,
4179 preferred_subject: &str,
4180 fallback_subject: &str,
4181 ) -> Result<bool> {
4182 let mut artifacts = IgnoredArtifacts::default();
4183 self.refuse_changed_attributes(cwd, baseline)?;
4184 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
4185 refuse_unsafe_index_flags(cwd)?;
4186 if !self.has_uncommitted_changes(cwd)? {
4187 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4188 artifacts.report(cwd);
4189 return Ok(false);
4190 }
4191 self.stage_managed_changes(cwd, baseline).map_err(|e| {
4192 e.with_message(format!(
4193 "could not stage changes in {}: {}",
4194 cwd.display(),
4195 e.last_line()
4196 ))
4197 })?;
4198 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4201 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
4202 if !changed_gitlinks.is_empty() {
4203 let listed = list_paths(&changed_gitlinks);
4204 bail!(
4205 "the editing call added or changed a gitlink at {listed}. It was staged but not \
4206 committed because the referenced repository objects might exist only inside \
4207 this worktree. The worktree was kept for recovery."
4208 );
4209 }
4210 let mut subject = self.clean_title(preferred_subject)?;
4211 if subject.trim().is_empty() {
4212 subject = self.clean_title(fallback_subject)?;
4213 }
4214 self.commit_staged_changes(cwd, &subject).map_err(|e| {
4215 e.with_message(format!(
4216 "could not commit changes in {}: {}. The staged files were kept.",
4217 cwd.display(),
4218 e.last_line()
4219 ))
4220 })?;
4221 if has_tracked_or_staged_work(cwd)? {
4222 bail!(
4223 "the commit in {} left additional uncommitted files. They were kept for \
4224 recovery.",
4225 cwd.display()
4226 );
4227 }
4228 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
4229 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4230 artifacts.report(cwd);
4231 Ok(true)
4232 }
4233
4234 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
4235 let after = ignored_untracked_state(cwd)?;
4236 self.git_at_without_automation(cwd, &["add", "-u"])?;
4237 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
4238 if paths.is_empty() {
4239 return Ok(());
4240 }
4241 let mut input = Vec::new();
4242 for path in paths {
4243 input.extend(os_str_bytes(path.as_os_str())?);
4244 input.push(0);
4245 }
4246 let argv = git_without_automation_argv(&[
4247 "--literal-pathspecs",
4248 "add",
4249 "--pathspec-from-file=-",
4250 "--pathspec-file-nul",
4251 ]);
4252 proc::run_with_input_bytes(
4253 &argv,
4254 &self.git_opts(Some(cwd), true).stop_descendants(true),
4255 &input,
4256 )?;
4257 Ok(())
4258 }
4259
4260 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
4263 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
4264 .map(|_| ())
4265 }
4266
4267 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
4274 let range = format!("{}..{refname}", self.base_ref(cwd, base));
4275 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
4276 .trim()
4277 .parse()
4278 .unwrap_or(0)
4279 }
4280
4281 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4285 let range = format!("{}..{refname}", self.base_ref(cwd, base));
4286 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4287 .lines()
4288 .map(str::to_string)
4289 .collect()
4290 }
4291
4292 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
4307 let ancestor = self
4308 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
4309 .is_ok();
4310 if !ancestor {
4311 return None;
4312 }
4313 let range = format!("{earlier}..{later}");
4314 Some(
4315 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4316 .lines()
4317 .map(str::to_string)
4318 .collect(),
4319 )
4320 }
4321
4322 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4325 let range = format!("{}..{refname}", self.base_ref(cwd, base));
4326 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
4327 .lines()
4328 .map(str::trim)
4329 .filter(|line| !line.is_empty())
4330 .map(str::to_string)
4331 .collect()
4332 }
4333
4334 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
4350 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4351 self.git_try_at(
4352 Some(cwd),
4353 &["diff", "--name-only", "--no-renames", "-z", &range],
4354 )
4355 .split('\0')
4356 .filter(|path| !path.is_empty())
4357 .map(str::to_string)
4358 .collect()
4359 }
4360
4361 pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
4376 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4377 let argv = git_without_maintenance_argv(&[
4378 "-c",
4379 "core.quotePath=false",
4380 "diff",
4381 "--no-color",
4382 "--no-ext-diff",
4383 "--no-textconv",
4384 "--unified=3",
4385 "--inter-hunk-context=0",
4386 "--diff-algorithm=myers",
4387 "--indent-heuristic",
4388 "--submodule=short",
4389 "--find-renames",
4390 "--src-prefix=a/",
4391 "--dst-prefix=b/",
4392 &range,
4393 ]);
4394 let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
4395 new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
4396 }
4397
4398 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
4401 self.git_try_at(
4402 Some(cwd),
4403 &[
4404 "diff",
4405 "--name-only",
4406 "--no-renames",
4407 "-z",
4408 &format!("{from}..{to}"),
4409 ],
4410 )
4411 .split('\0')
4412 .filter(|path| !path.is_empty())
4413 .map(str::to_string)
4414 .collect()
4415 }
4416
4417 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
4420 let base_ref = self.base_ref(cwd, base);
4421 let out = self
4422 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
4423 .map_err(|e| {
4424 spar_err!(
4425 "could not find where {refname} and {base_ref} diverged. {}",
4426 e.last_line()
4427 )
4428 })?;
4429 let sha = out.trim().to_string();
4430 if sha.is_empty() {
4431 bail!("{refname} and {base_ref} share no history");
4432 }
4433 Ok(sha)
4434 }
4435
4436 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
4437 let range = format!("{}...HEAD", self.base_ref(cwd, base));
4438 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
4439 full.trim().to_string()
4440 }
4441
4442 pub fn rewrite_commits_if_needed(
4455 &self,
4456 cwd: &Path,
4457 base: &str,
4458 floor: Option<&str>,
4459 ) -> Result<()> {
4460 let base_ref = self.base_ref(cwd, base);
4461 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
4462 self.report_commits_left_alone(cwd, &base_ref, floor);
4463
4464 let range = format!("{floor}..HEAD");
4465 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
4466
4467 let offenders = raw
4468 .split('\x1e')
4469 .filter_map(|entry| entry.split_once('\0'))
4470 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4471 .count();
4472 if offenders == 0 {
4473 return Ok(());
4474 }
4475 logdim!("{offenders} commit message(s) violated style rules, rewriting");
4476
4477 let exe = self_binary()?;
4478 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
4479
4480 let argv: Vec<String> = [
4481 "git",
4482 "filter-branch",
4483 "-f",
4484 "--msg-filter",
4485 &filter,
4486 &range,
4487 ]
4488 .iter()
4489 .map(|s| s.to_string())
4490 .collect();
4491 let opts = ExecOpts::new()
4492 .cwd(cwd)
4493 .check(false)
4494 .timeout_secs(600)
4495 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4496 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4497 .env(
4498 "SPAR_BAN_AI_ATTRIBUTION",
4499 bool_env(self.style.ban_ai_attribution),
4500 );
4501 let _ = proc::run(&argv, &opts);
4502
4503 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4504 if !style::violations(&after, &self.style).is_empty() {
4505 bail!(
4506 "commit messages still violate style rules after a rewrite in {}.",
4507 cwd.display()
4508 );
4509 }
4510 Ok(())
4511 }
4512
4513 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4518 if floor == base_ref {
4519 return;
4520 }
4521 let raw = self.git_try_at(
4522 Some(cwd),
4523 &[
4524 "log",
4525 &format!("{base_ref}..{floor}"),
4526 "--format=%H%x00%B%x1e",
4527 ],
4528 );
4529 let left: Vec<&str> = raw
4530 .split('\x1e')
4531 .filter_map(|entry| entry.split_once('\0'))
4532 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4533 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4534 .collect();
4535 if left.is_empty() {
4536 return;
4537 }
4538 logdim!(
4539 "{} commit message(s) already on the branch break the style rules and were left \
4540 alone: {}. spar does not rewrite commits it did not write.",
4541 left.len(),
4542 left.join(", ")
4543 );
4544 }
4545
4546 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4561 self.push_to(cwd, &PushTarget::Origin, branch, expected)
4562 }
4563
4564 pub fn push_to(
4566 &self,
4567 cwd: &Path,
4568 target: &PushTarget,
4569 branch: &str,
4570 expected: Option<&str>,
4571 ) -> Result<()> {
4572 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4573 if let Some(head) = expected {
4574 self.refuse_push_that_drops(cwd, head)?;
4575 }
4576 let pushed = self
4577 .git_push(cwd, target, branch, expected)
4578 .map_err(|e| push_error(target, branch, &e));
4579 self.record_write(pushed)
4580 }
4581
4582 pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4594 let origin = PushTarget::Origin;
4595 let refused = match self.git_push(cwd, &origin, branch, None) {
4596 Ok(()) => return self.record_write(Ok(origin)),
4597 Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4598 Err(e) => {
4599 let err = push_error(&origin, branch, &e);
4600 let err = if push_denial(&e.to_string()).is_some() {
4601 spar_err!(
4602 "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4603 instead."
4604 )
4605 } else {
4606 err
4607 };
4608 return self.record_write(Err(err));
4609 }
4610 };
4611 self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4612 }
4613
4614 fn publish_on_fork(
4615 &self,
4616 cwd: &Path,
4617 branch: &str,
4618 base: &str,
4619 refused: &crate::error::SparError,
4620 ) -> Result<PushTarget> {
4621 let fork = self.fork().map_err(|e| {
4622 spar_err!(
4623 "{} spar could not publish it from a fork instead: {}",
4624 push_error(&PushTarget::Origin, branch, refused),
4625 e.last_line()
4626 )
4627 })?;
4628 let lease = self.fork_branch_lease(&fork, branch, base)?;
4629 let refused = refused.to_string();
4630 log!(
4631 "{} Publishing {branch} from the fork {} instead.",
4632 push_denial(&refused).unwrap_or("origin refused the push."),
4633 fork.full_name
4634 );
4635 let target = PushTarget::Fork(fork);
4636 self.git_push(cwd, &target, branch, lease.as_deref())
4639 .map_err(|e| push_error(&target, branch, &e))?;
4640 Ok(target)
4641 }
4642
4643 fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4651 let live_ref = format!("refs/heads/{branch}");
4652 let unreadable = |e: crate::error::SparError| {
4653 spar_err!(
4654 "could not check whether the fork {} already has {branch}. {}",
4655 fork.full_name,
4656 e.last_line()
4657 )
4658 };
4659 let listed = self
4660 .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4661 .map_err(unreadable)?;
4662 let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4663 return Ok(None);
4664 };
4665 let local = format!("refs/spar/fork/{branch}");
4666 self.git(&[
4667 "fetch",
4668 "--no-tags",
4669 &fork.url,
4670 &format!("+{live_ref}:{local}"),
4671 ])
4672 .map_err(unreadable)?;
4673 let ahead = self.commit_count_checked(&self.root, &local, base);
4674 let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4675 self.git_try(&["update-ref", "-d", &local]);
4676 let ahead = ahead?;
4677 if !held {
4678 bail!(
4679 "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4680 and no pull request accounts for them. Publishing over it would lose that work.\n\
4681 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4682 run spar again.",
4683 fork.full_name,
4684 fork.url
4685 );
4686 }
4687 Ok(Some(tip))
4688 }
4689
4690 fn git_push(
4691 &self,
4692 cwd: &Path,
4693 target: &PushTarget,
4694 branch: &str,
4695 expected: Option<&str>,
4696 ) -> Result<()> {
4697 let refspec = format!("HEAD:refs/heads/{branch}");
4698 let lease = match (expected, target) {
4699 (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4700 (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4704 (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4705 };
4706 self.push_retrying(
4707 cwd,
4708 target.remote(),
4709 branch,
4710 &target.describe(branch),
4711 &["push", &lease, target.remote(), &refspec],
4712 )
4713 }
4714
4715 fn push_retrying(
4718 &self,
4719 cwd: &Path,
4720 remote: &str,
4721 branch: &str,
4722 label: &str,
4723 args: &[&str],
4724 ) -> Result<()> {
4725 retry_transient_push(
4726 label,
4727 || self.git_at(Some(cwd), args).map(|_| ()),
4728 || self.remote_branch_is_head(cwd, remote, branch),
4729 &PUSH_RETRY_WAITS,
4730 proc::sleep_unless_interrupted,
4731 )
4732 }
4733
4734 fn remote_branch_is_head(&self, cwd: &Path, remote: &str, branch: &str) -> bool {
4737 let remote_ref = format!("refs/heads/{branch}");
4738 let (Ok(head), Ok(listed)) = (
4739 self.git_at(Some(cwd), &["rev-parse", "HEAD"]),
4740 self.git_at(Some(cwd), &["ls-remote", "--heads", remote, &remote_ref]),
4741 ) else {
4742 return false;
4743 };
4744 matches!(remote_head_oid(&listed, &remote_ref), Ok(Some(oid)) if oid == head.trim())
4745 }
4746
4747 fn fork(&self) -> Result<Fork> {
4754 match self.existing_fork() {
4755 Ok(Some(fork)) => return Ok(fork),
4756 Ok(None) => {}
4757 Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4758 }
4759 let answer = self
4760 .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4761 .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4762 let fork = fork_from_api(&answer, &self.origin_push_url())?;
4763 self.wait_for_fork(&fork)?;
4764 Ok(self.fork.get_or_init(|| fork).clone())
4765 }
4766
4767 fn existing_fork(&self) -> Result<Option<Fork>> {
4770 if let Some(fork) = self.fork.get() {
4771 return Ok(Some(fork.clone()));
4772 }
4773 let answer = self
4774 .gh(&[
4775 "api",
4776 "graphql",
4777 "-F",
4778 "owner={owner}",
4779 "-F",
4780 "name={repo}",
4781 "-f",
4782 "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4783 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4784 login } sshUrl url } } } }",
4785 "--jq",
4786 ".data.repository.forks.nodes",
4787 ])
4788 .map_err(|e| {
4789 spar_err!(
4790 "could not look for a fork of this repository. {}",
4791 e.last_line()
4792 )
4793 })?;
4794 let found = fork_from_listing(&answer, &self.origin_push_url())?;
4795 Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4796 }
4797
4798 fn origin_refuses_push(&self) -> bool {
4802 self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4803 .trim()
4804 == "false"
4805 }
4806
4807 fn origin_push_url(&self) -> String {
4808 self.git_try(&["remote", "get-url", "--push", "origin"])
4809 }
4810
4811 pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4816 if !pr.is_cross_repository {
4817 return Some(PushTarget::Origin);
4818 }
4819 let viewer = self.viewer_login().ok()?;
4820 own_fork_target(pr, viewer, &self.origin_push_url())
4821 }
4822
4823 fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4824 const TRIES: u32 = 30;
4825 let mut tries = 1;
4826 loop {
4827 match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4828 Ok(_) => return Ok(()),
4829 Err(e) if tries >= TRIES => bail!(
4830 "the fork {} could not be reached after {} seconds. {}",
4831 fork.full_name,
4832 TRIES * 2,
4833 e.last_line()
4834 ),
4835 Err(_) => {
4836 tries += 1;
4837 std::thread::sleep(std::time::Duration::from_secs(2));
4838 }
4839 }
4840 }
4841 }
4842
4843 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4851 let head = self.head_oid_checked(cwd)?;
4852 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4853 return Ok(());
4854 }
4855 bail!(
4856 "refusing to push {head} because it does not contain the reviewed head {expected}. \
4857 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4858 the intended commits on top of it before resuming.",
4859 cwd.display()
4860 )
4861 }
4862
4863 pub fn push_split_branch(
4872 &self,
4873 cwd: &Path,
4874 branch: &str,
4875 ) -> std::result::Result<(), SplitPushError> {
4876 let remote_ref = format!("refs/heads/{branch}");
4877 let lease = format!("--force-with-lease={remote_ref}:");
4878 let refspec = format!("HEAD:{remote_ref}");
4879 let pushed = self.push_retrying(
4880 cwd,
4881 "origin",
4882 branch,
4883 &format!("origin/{branch}"),
4884 &["push", &lease, "origin", &refspec],
4885 );
4886 let result = match pushed {
4887 Ok(()) => Ok(()),
4888 Err(push_error) => {
4889 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4890 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4891 reconcile_failed_split_push(branch, push_error, local, remote)
4892 }
4893 };
4894 self.record_write(result)
4895 }
4896
4897 pub fn gh(&self, args: &[&str]) -> Result<String> {
4900 self.gh_at(None, args)
4901 }
4902
4903 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4904 let mut argv = vec!["gh".to_string()];
4905 argv.extend(args.iter().map(|s| s.to_string()));
4906 proc::run(
4907 &argv,
4908 &ExecOpts::new()
4909 .cwd(cwd.unwrap_or(&self.root))
4910 .timeout_secs(300),
4911 )
4912 }
4913
4914 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4920 let mut argv = vec!["gh".to_string()];
4921 argv.extend(args.iter().map(|s| s.to_string()));
4922 proc::run(
4923 &argv,
4924 &ExecOpts::new()
4925 .cwd(&self.root)
4926 .timeout_secs(300)
4927 .stdin(stdin),
4928 )
4929 }
4930
4931 pub fn gh_try(&self, args: &[&str]) -> String {
4932 let mut argv = vec!["gh".to_string()];
4933 argv.extend(args.iter().map(|s| s.to_string()));
4934 proc::run(
4935 &argv,
4936 &ExecOpts::new()
4937 .cwd(&self.root)
4938 .check(false)
4939 .timeout_secs(300),
4940 )
4941 .unwrap_or_default()
4942 }
4943
4944 pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4949 let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4950 for (name, value) in headers {
4951 argv.push("-H".to_string());
4952 argv.push(format!("{name}: {value}"));
4953 }
4954 argv.push(endpoint.to_string());
4955 let out = proc::exec(
4956 &argv,
4957 &ExecOpts::new()
4958 .cwd(&self.root)
4959 .check(false)
4960 .timeout_secs(300),
4961 )?;
4962 parse_api_response(&out.stdout)
4963 .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4964 }
4965
4966 pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4968 let text = self.gh(&[
4969 "pr",
4970 "list",
4971 "--state",
4972 "open",
4973 "--limit",
4974 "500",
4975 "--json",
4976 PR_SWEEP_FIELDS,
4977 ])?;
4978 serde_json::from_str(&text)
4979 .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4980 }
4981
4982 pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4984 let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4985 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4986 }
4987
4988 pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4993 let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4995 if slug.is_empty() || login.is_empty() {
4996 return None;
4997 }
4998 let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4999 let resp = self.gh_api_raw(&path, &[]).ok()?;
5000 match resp.status {
5001 200 => {
5002 let body: Value = serde_json::from_str(&resp.body).ok()?;
5003 Some(body.get("state")?.as_str()? == "active")
5004 }
5005 404 => Some(false),
5006 _ => None,
5007 }
5008 }
5009
5010 pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
5017 let text = self
5018 .gh(&[
5019 "api",
5020 "graphql",
5021 "-F",
5022 "owner={owner}",
5023 "-F",
5024 "repo={repo}",
5025 "-F",
5026 &format!("number={number}"),
5027 "-f",
5028 &format!("query={REQUESTS_QUERY}"),
5029 ])
5030 .ok()?;
5031 parse_review_requests(&text)
5032 }
5033
5034 pub fn sweep_assigned_issues(&self, login: &str) -> Result<Vec<crate::model::AssignedIssue>> {
5039 let text = self.gh(&[
5040 "api",
5041 "graphql",
5042 "-F",
5043 "owner={owner}",
5044 "-F",
5045 "repo={repo}",
5046 "-f",
5047 &format!("login={login}"),
5048 "-f",
5049 &format!("query={}", assigned_issues_query()),
5050 ])?;
5051 parse_assigned_issues(&text)
5052 .ok_or_else(|| spar_err!("unexpected shape for the issues assigned to {login}"))
5053 }
5054
5055 pub fn issue_assignments(&self, number: i64) -> Result<crate::model::AssignedIssue> {
5058 let text = self.gh(&[
5059 "api",
5060 "graphql",
5061 "-F",
5062 "owner={owner}",
5063 "-F",
5064 "repo={repo}",
5065 "-F",
5066 &format!("number={number}"),
5067 "-f",
5068 &format!("query={}", issue_assignments_query()),
5069 ])?;
5070 parse_issue_assignments(&text)
5071 .ok_or_else(|| spar_err!("unexpected shape for the assignments on #{number}"))
5072 }
5073
5074 pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
5076 if from.is_empty() || to.is_empty() {
5077 return None;
5078 }
5079 let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
5080 self.gh_try(&["api", &path, "--jq", ".total_commits"])
5081 .trim()
5082 .parse()
5083 .ok()
5084 }
5085
5086 pub fn repo_name(&self) -> Option<String> {
5088 let name = self.gh_try(&[
5089 "repo",
5090 "view",
5091 "--json",
5092 "nameWithOwner",
5093 "--jq",
5094 ".nameWithOwner",
5095 ]);
5096 let name = name.trim();
5097 (!name.is_empty()).then(|| name.to_string())
5098 }
5099
5100 pub fn listen_state_path(&self) -> PathBuf {
5101 self.root.join(STATE_DIR).join("state").join("listen.json")
5102 }
5103
5104 pub fn listen_lock_path(&self) -> PathBuf {
5105 self.root.join(STATE_DIR).join("state").join("listen.lock")
5106 }
5107
5108 pub fn viewer_login(&self) -> Result<&str> {
5119 if let Some(login) = self.viewer.get() {
5120 return Ok(login);
5121 }
5122 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
5123 let login = if !rest.trim().is_empty() {
5124 rest.trim().to_string()
5125 } else {
5126 self.gh(&[
5129 "api",
5130 "graphql",
5131 "-f",
5132 "query={ viewer { login } }",
5133 "--jq",
5134 ".data.viewer.login",
5135 ])
5136 .map_err(|e| {
5137 spar_err!(
5138 "could not find out who `gh` is authenticated as, so spar cannot tell its \
5139 own comments from anybody else's. {}\nRun `gh auth status`.",
5140 e.last_line()
5141 )
5142 })?
5143 .trim()
5144 .to_string()
5145 };
5146 if login.is_empty() {
5147 bail!("`gh` reported an empty login. Run `gh auth status`.");
5148 }
5149 Ok(self.viewer.get_or_init(|| login))
5150 }
5151
5152 pub fn read_issue(&self, number: i64) -> Result<Issue> {
5158 let text = self
5159 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
5160 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
5161 serde_json::from_str(&text)
5162 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
5163 }
5164
5165 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
5166 let mut issues = Vec::new();
5167 for number in numbers {
5168 let text = self
5169 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
5170 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
5171 let issue: Issue = serde_json::from_str(&text)
5172 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5173 if issue.is_closed() {
5174 crate::log!("issue #{number} is closed, skipping");
5175 continue;
5176 }
5177 issues.push(issue);
5178 }
5179 if issues.is_empty() {
5180 bail!("no open issues to work on");
5181 }
5182 Ok(issues)
5183 }
5184
5185 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5191 #[derive(Deserialize)]
5192 struct Row {
5193 number: i64,
5194 }
5195 let text = self.gh(&[
5196 kind,
5197 "list",
5198 "--state",
5199 "open",
5200 "--limit",
5201 &FETCH_CEILING.to_string(),
5202 "--json",
5203 "number",
5204 ])?;
5205 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5206 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
5207 numbers.sort_unstable();
5208
5209 let noun = if kind == "issue" { "issues" } else { "PRs" };
5210 let found = numbers.len();
5211 if min_number > 0 {
5212 numbers.retain(|n| *n >= min_number);
5213 let skipped = found - numbers.len();
5214 if skipped > 0 {
5215 crate::log!("{skipped} open {noun} below #{min_number} skipped");
5216 }
5217 }
5218 if found >= FETCH_CEILING {
5219 crate::log!(
5220 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
5221 considered."
5222 );
5223 }
5224 if numbers.len() > limit {
5225 crate::log!(
5226 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
5227 explicitly.",
5228 numbers.len()
5229 );
5230 numbers.truncate(limit);
5231 }
5232 Ok(numbers)
5233 }
5234
5235 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5237 self.open_numbers("issue", limit, min_number)
5238 }
5239
5240 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5241 self.open_numbers("pr", limit, min_number)
5242 }
5243
5244 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
5245 self.branch_prs(branch, "open").into_iter().next()
5246 }
5247
5248 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
5251 let text = self.gh(&[
5252 "pr",
5253 "list",
5254 "--head",
5255 branch,
5256 "--base",
5257 base,
5258 "--state",
5259 "open",
5260 "--json",
5261 "number,url,title,baseRefName",
5262 ])?;
5263 pr_for_base(&text, branch, base)
5264 }
5265
5266 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
5270 self.branch_prs(branch, "all")
5271 }
5272
5273 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
5274 let text = self.gh_try(&[
5275 "pr",
5276 "list",
5277 "--head",
5278 branch,
5279 "--state",
5280 state,
5281 "--json",
5282 "number,url,title",
5283 ]);
5284 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
5285 }
5286
5287 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
5293 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
5294 let text = self
5295 .gh(&[
5296 "api",
5297 &path,
5298 "--jq",
5299 "if .pull_request then \"pr\" else \"issue\" end",
5300 ])
5301 .map_err(|e| {
5302 spar_err!(
5303 "no issue or pull request #{number} in this repository. {}",
5304 e.last_line()
5305 )
5306 })?;
5307 match text.trim() {
5308 "pr" => Ok(ItemKind::Pr),
5309 "issue" => Ok(ItemKind::Issue),
5310 other => Err(spar_err!(
5311 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
5312 )),
5313 }
5314 }
5315
5316 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
5327 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
5328 if let Some(pr) = mine {
5329 return Ok(Some(pr));
5330 }
5331 let listed = match self.gh(&[
5332 "pr",
5333 "list",
5334 "--state",
5335 "open",
5336 "--limit",
5337 &FETCH_CEILING.to_string(),
5338 "--json",
5339 "number,url,title,baseRefName,closingIssuesReferences",
5340 ]) {
5341 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
5342 spar_err!("could not read the pull request list while looking for #{issue}")
5343 })?,
5344 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
5345 };
5346 if listed.len() >= FETCH_CEILING {
5347 logwarn!(
5348 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
5349 );
5350 }
5351 let default = self.default_branch(base);
5352 if !base.eq_ignore_ascii_case(&default) {
5353 logwarn!(
5357 "#{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."
5358 );
5359 }
5360 Ok(pick_linked_pr(&listed, issue))
5361 }
5362
5363 fn pr_for_branch_failing_closed(
5365 &self,
5366 branch: &str,
5367 base: &str,
5368 issue: i64,
5369 ) -> Result<Option<PrRef>> {
5370 match self.try_pr_for_branch(branch, base) {
5371 Ok(found) => Ok(found),
5372 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
5373 }
5374 }
5375
5376 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
5385 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
5386 return Err(spar_err!(
5387 "could not check whether a pull request already exists for #{issue}: {}. \
5388 Refusing to implement over the top of work that may already be open.",
5389 e.last_line()
5390 ));
5391 }
5392 logwarn!(
5393 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
5394 whether a pull request already exists for it.",
5395 e.last_line()
5396 );
5397 Ok(())
5398 }
5399
5400 pub fn pr_view(&self, number: i64) -> Result<PrView> {
5401 let text = self.gh(&[
5402 "pr",
5403 "view",
5404 &number.to_string(),
5405 "--json",
5406 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
5407 isCrossRepository,headRepositoryOwner,headRepository",
5408 ])?;
5409 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
5410 }
5411
5412 fn try_pr_state(&self, number: i64) -> Result<String> {
5413 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
5414 serde_json::from_str::<Value>(text.trim())
5415 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
5416 .get("state")
5417 .and_then(Value::as_str)
5418 .map(str::to_string)
5419 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
5420 }
5421
5422 pub fn pr_state(&self, number: i64) -> String {
5423 self.try_pr_state(number).unwrap_or_default()
5424 }
5425
5426 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
5428 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
5429 let oid = serde_json::from_str::<Value>(&text)
5430 .ok()
5431 .and_then(|value| {
5432 value
5433 .get("headRefOid")
5434 .and_then(Value::as_str)
5435 .map(str::trim)
5436 .filter(|oid| !oid.is_empty())
5437 .map(str::to_string)
5438 })
5439 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
5440 Ok(oid)
5441 }
5442
5443 pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
5447 let pull = self.pr_head_oid(pr.number)?;
5448 let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
5449 None
5450 } else {
5451 let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
5452 let listed = self
5453 .git(&["ls-remote", "--heads", "origin", &remote_ref])
5454 .map_err(|e| {
5455 spar_err!(
5456 "could not read where {} is on origin: {}",
5457 pr.head_ref_name.trim(),
5458 e.last_line()
5459 )
5460 })?;
5461 remote_head_oid(&listed, &remote_ref)?
5462 };
5463 Ok(LiveHead { pull, branch })
5464 }
5465
5466 pub fn create_pr(
5471 &self,
5472 cwd: &Path,
5473 branch: &str,
5474 target: &PushTarget,
5475 base: &str,
5476 title: &str,
5477 body: &str,
5478 ) -> Result<PrRef> {
5479 let title = self.record_failed_write(self.clean_title(title))?;
5480 let body = self.record_failed_write(self.clean(body))?;
5481 let head = target.head(branch);
5482 let mut argv = vec![
5483 "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
5484 ];
5485 if self.drafts != Drafts::Never {
5486 argv.push("--draft");
5487 }
5488 let created = self.gh_at(Some(cwd), &argv);
5489 let found = self.try_pr_for_branch(branch, base);
5490 self.record_write(reconcile_pr_creation(branch, created, found))
5491 }
5492
5493 pub fn comment_pr(&self, number: i64, body: &str) -> Result<Option<String>> {
5496 let body = signed(&self.record_failed_write(self.clean(body))?);
5497 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5498 if let Some(comment) = exact_comment(&comments, &body) {
5499 return Ok(html_url(comment));
5500 }
5501 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
5502 let result = match posted {
5503 Ok(stdout) => Ok(printed_url(&stdout)),
5504 Err(post_error) => {
5505 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5506 }
5507 };
5508 self.record_write(result)
5509 }
5510
5511 fn pr_is_own(&self, number: i64) -> Result<bool> {
5515 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
5516 let author = serde_json::from_str::<Value>(&text)
5517 .ok()
5518 .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
5519 .unwrap_or_default();
5520 let viewer = self.viewer_login()?.to_string();
5521 Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
5522 }
5523
5524 pub fn submit_review(
5552 &self,
5553 number: i64,
5554 verdict: Option<crate::model::ReviewVerdict>,
5555 body: &str,
5556 full: &str,
5557 commit: &str,
5558 comments: &[InlineComment],
5559 ) -> Result<Option<String>> {
5560 let own = match verdict {
5561 Some(_) => self.record_failed_write(self.pr_is_own(number))?,
5562 None => false,
5563 };
5564 let prepare = |body: &str| {
5565 let text = match (verdict, own) {
5566 (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
5567 _ => body.to_string(),
5568 };
5569 self.record_failed_write(self.clean(&text))
5570 .map(|b| signed(&b))
5571 };
5572 let body = prepare(body)?;
5573 let full = prepare(full)?;
5574 let mut inline = Vec::with_capacity(comments.len());
5575 for c in comments {
5576 inline.push(InlineComment {
5577 body: signed(&self.record_failed_write(self.clean(&c.body))?),
5578 ..c.clone()
5579 });
5580 }
5581 let either = [body.as_str(), full.as_str()];
5582 let viewer = self.record_failed_write(self.viewer_login())?.to_string();
5583 let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
5584 if let Some(review) = viewer_review(&reviews, &viewer, &either, commit) {
5585 return Ok(html_url(review));
5586 }
5587 let event = match (verdict, own) {
5588 (Some(v), false) => v.api_event(),
5589 _ => "COMMENT",
5590 };
5591 let result = if inline.is_empty() {
5592 self.post_review(number, &full, &viewer, commit, event)
5593 } else {
5594 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5595 let payload = review_payload(commit, event, &body, &inline).to_string();
5596 match self.gh_stdin(&["api", "-X", "POST", &path, "--input", "-"], &payload) {
5597 Ok(response) => Ok(review_url(&response)),
5598 Err(post_error) if !refused(&post_error) => reconcile_review_post(
5599 number,
5600 &[&body],
5601 &viewer,
5602 commit,
5603 post_error,
5604 self.try_pr_reviews(number),
5605 ),
5606 Err(post_error) => match self.try_pr_reviews(number) {
5607 Ok(reviews) if !has_viewer_review(&reviews, &viewer, &either, commit) => {
5608 logwarn!(
5609 "the review of #{number} with its line comments did not go up ({}), \
5610 so it is going up with every finding in its body only",
5611 post_error.last_line()
5612 );
5613 self.post_review(number, &full, &viewer, commit, event)
5614 }
5615 reviews => {
5616 reconcile_review_post(number, &either, &viewer, commit, post_error, reviews)
5617 }
5618 },
5619 }
5620 };
5621 self.record_write(result)
5622 }
5623
5624 fn post_review(
5626 &self,
5627 number: i64,
5628 body: &str,
5629 viewer: &str,
5630 commit: &str,
5631 event: &str,
5632 ) -> Result<Option<String>> {
5633 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5634 let posted = self.gh(&[
5635 "api",
5636 "-X",
5637 "POST",
5638 &path,
5639 "-f",
5640 &format!("commit_id={commit}"),
5641 "-f",
5642 &format!("event={event}"),
5643 "-f",
5644 &format!("body={body}"),
5645 ]);
5646 match posted {
5647 Ok(response) => Ok(review_url(&response)),
5648 Err(post_error) => reconcile_review_post(
5649 number,
5650 &[body],
5651 viewer,
5652 commit,
5653 post_error,
5654 self.try_pr_reviews(number),
5655 ),
5656 }
5657 }
5658
5659 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5660 let body = signed(&self.record_failed_write(self.clean(body))?);
5661 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5662 if exact_comment(&comments, &body).is_some() {
5663 return Ok(());
5664 }
5665 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5666 let result = match posted {
5667 Ok(_) => Ok(()),
5668 Err(post_error) => {
5669 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5670 .map(|_| ())
5671 }
5672 };
5673 self.record_write(result)
5674 }
5675
5676 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5681 self.comment_issue(number, body)?;
5682 let n = number.to_string();
5683 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5684 Ok(_) => Ok(()),
5685 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5687 spar_err!(
5688 "commented on #{number} but could not close it: {}",
5689 e.last_line()
5690 )
5691 }),
5692 };
5693 self.record_write(closed)
5694 }
5695
5696 pub fn edit_issue_body(
5711 &self,
5712 number: i64,
5713 expected: &str,
5714 body: &str,
5715 inserted: &str,
5716 ) -> Result<()> {
5717 let cleaned = self.record_failed_write(self.clean(inserted))?;
5718 if cleaned.trim() != inserted.trim() {
5719 return self.record_failed_write(Err(spar_err!(
5720 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5721 )));
5722 }
5723 let current = self.record_failed_write(self.issue_body(number))?;
5724 if current != expected {
5725 return self.record_failed_write(Err(spar_err!(
5726 "the body of #{number} changed since it was read, so it was left alone rather \
5727 than written over."
5728 )));
5729 }
5730 let edited = self.gh_stdin(
5731 &["issue", "edit", &number.to_string(), "--body-file", "-"],
5732 body,
5733 );
5734 let result = match edited {
5735 Ok(_) => Ok(()),
5736 Err(edit_error) => {
5737 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5738 }
5739 };
5740 self.record_write(result)
5741 }
5742
5743 pub fn issue_body(&self, number: i64) -> Result<String> {
5745 #[derive(Deserialize)]
5746 struct Row {
5747 #[serde(default)]
5748 body: Option<String>,
5749 }
5750 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5751 let row: Row = serde_json::from_str(text.trim())
5752 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5753 Ok(row.body.unwrap_or_default())
5754 }
5755
5756 pub fn open_issue_rows(&self) -> Vec<Issue> {
5761 let text = self.gh_try(&[
5762 "issue",
5763 "list",
5764 "--state",
5765 "open",
5766 "--limit",
5767 &FETCH_CEILING.to_string(),
5768 "--json",
5769 "number,title,body,labels,state,stateReason,url",
5770 ]);
5771 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5772 }
5773
5774 pub fn open_pr_rows(&self) -> Vec<PrRow> {
5776 let text = self.gh_try(&[
5777 "pr",
5778 "list",
5779 "--state",
5780 "open",
5781 "--limit",
5782 &FETCH_CEILING.to_string(),
5783 "--json",
5784 "number,title,changedFiles,additions,deletions",
5785 ]);
5786 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5787 }
5788
5789 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5790 self.create_issue_apart_from(title, body, None)
5791 }
5792
5793 pub fn create_issue_apart_from(
5794 &self,
5795 title: &str,
5796 body: &str,
5797 apart_from: Option<i64>,
5798 ) -> Result<String> {
5799 let title = self.record_failed_write(self.clean_title(title))?;
5800 let body = self.record_failed_write(self.clean_issue_body(body))?;
5801 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5802 let result = match created {
5803 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5804 created => {
5805 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5806 reconcile_issue_creation(&title, created, found)
5807 }
5808 };
5809 self.record_write(result)
5810 }
5811}
5812
5813#[derive(Debug, Clone)]
5815pub struct ExistingIssue {
5816 pub number: i64,
5817 pub url: String,
5818 pub title: String,
5819 pub body: String,
5820 pub open: bool,
5821}
5822
5823impl Repo {
5824 pub(crate) fn try_exact_issue_apart_from(
5825 &self,
5826 title: &str,
5827 body: &str,
5828 apart_from: Option<i64>,
5829 ) -> Result<Option<ExistingIssue>> {
5830 #[derive(Deserialize)]
5831 #[serde(rename_all = "camelCase")]
5832 struct Row {
5833 number: i64,
5834 #[serde(default)]
5835 title: String,
5836 #[serde(default)]
5837 url: String,
5838 #[serde(default)]
5839 body: Option<String>,
5840 #[serde(default)]
5841 state: String,
5842 }
5843
5844 let text = self.gh(&[
5845 "issue",
5846 "list",
5847 "--state",
5848 "all",
5849 "--limit",
5850 "100",
5851 "--json",
5852 "number,title,url,body,state,stateReason",
5853 ])?;
5854 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5855 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5856 Ok(rows
5857 .into_iter()
5858 .filter(|row| Some(row.number) != apart_from)
5859 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5860 .map(|row| ExistingIssue {
5861 number: row.number,
5862 url: row.url,
5863 title: row.title,
5864 body: row.body.unwrap_or_default(),
5865 open: row.state.eq_ignore_ascii_case("open"),
5866 }))
5867 }
5868
5869 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5877 self.find_similar_issue_apart_from(title, body, None)
5878 }
5879
5880 pub fn find_similar_issue_apart_from(
5888 &self,
5889 title: &str,
5890 body: &str,
5891 apart_from: Option<i64>,
5892 ) -> Option<ExistingIssue> {
5893 self.try_similar_issue(title, body, apart_from, true)
5894 .ok()
5895 .flatten()
5896 }
5897
5898 pub fn try_find_similar_issue_apart_from(
5900 &self,
5901 title: &str,
5902 body: &str,
5903 apart_from: Option<i64>,
5904 ) -> Result<Option<ExistingIssue>> {
5905 self.try_similar_issue(title, body, apart_from, false)
5906 }
5907
5908 fn try_similar_issue(
5910 &self,
5911 title: &str,
5912 body: &str,
5913 apart_from: Option<i64>,
5914 include_closed: bool,
5915 ) -> Result<Option<ExistingIssue>> {
5916 #[derive(Deserialize)]
5917 #[serde(rename_all = "camelCase")]
5918 struct Row {
5919 number: i64,
5920 #[serde(default)]
5921 title: String,
5922 #[serde(default)]
5923 url: String,
5924 #[serde(default)]
5925 body: String,
5926 #[serde(default)]
5927 state: String,
5928 }
5929 if title.trim().is_empty() {
5930 return Ok(None);
5931 }
5932 let query: String = title
5935 .chars()
5936 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5937 .take(120)
5938 .collect();
5939 let state = if include_closed { "all" } else { "open" };
5948 let text = self.gh(&[
5949 "issue",
5950 "list",
5951 "--state",
5952 state,
5953 "--limit",
5954 "100",
5955 "--search",
5956 query.trim(),
5957 "--json",
5958 "number,title,url,body,state,stateReason",
5959 ])?;
5960 let rows: Vec<Row> = serde_json::from_str(text.trim())
5961 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5962
5963 Ok(rows
5964 .into_iter()
5965 .filter(|row| Some(row.number) != apart_from)
5966 .find(|row| {
5967 row.title.trim().eq_ignore_ascii_case(title.trim())
5968 || textsim::covers(title, body, &row.title, &row.body)
5969 })
5970 .map(|row| ExistingIssue {
5971 number: row.number,
5972 url: row.url,
5973 title: row.title,
5974 open: row.state.eq_ignore_ascii_case("open"),
5975 body: row.body,
5976 }))
5977 }
5978
5979 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5981 #[derive(Deserialize)]
5982 struct Row {
5983 title: String,
5984 url: String,
5985 }
5986 let needle = title.trim().to_lowercase();
5987 if needle.is_empty() {
5988 return None;
5989 }
5990 let query: String = title
5992 .chars()
5993 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5994 .take(120)
5995 .collect();
5996 let text = self.gh_try(&[
5997 "issue",
5998 "list",
5999 "--state",
6000 "all",
6001 "--limit",
6002 "100",
6003 "--search",
6004 query.trim(),
6005 "--json",
6006 "number,title,url",
6007 ]);
6008 serde_json::from_str::<Vec<Row>>(text.trim())
6009 .ok()?
6010 .into_iter()
6011 .find(|row| row.title.trim().to_lowercase() == needle)
6012 .map(|row| row.url)
6013 }
6014
6015 pub fn mark_ready(&self, number: i64) -> bool {
6023 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
6024 Ok(_) => true,
6025 Err(e) => {
6026 logdim!(
6027 "PR #{number} is approved but could not be taken out of draft: {}",
6028 e.last_line()
6029 );
6030 false
6031 }
6032 }
6033 }
6034
6035 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
6037 let listed = self
6038 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
6039 .ok()?;
6040 let wanted = format!("branch refs/heads/{branch}");
6041 let mut current: Option<PathBuf> = None;
6042 for line in listed.lines() {
6043 if let Some(path) = line.strip_prefix("worktree ") {
6044 current = Some(PathBuf::from(path));
6045 } else if line == wanted {
6046 return current;
6047 }
6048 }
6049 None
6050 }
6051
6052 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
6068 let branch = self.pr_view(number).ok()?.head_ref_name;
6069 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
6070 return None;
6071 }
6072 let mut released = None;
6073 if let Some(path) = self.worktree_holding(&branch) {
6074 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
6075 return None;
6076 }
6077 if !matches!(
6078 self.has_recoverable_work(&path, Disposable::BuildOutput),
6079 Ok(false)
6080 ) {
6081 return None;
6082 }
6083 match self.remove_worktree_at(&path) {
6084 Ok(true) => released = Some(path),
6085 _ => return None,
6086 }
6087 }
6088 match self.delete_branch_if_safe(&branch) {
6089 Ok(true) => {
6090 self.forget_branch(&branch);
6091 Some(match released {
6092 Some(path) => format!(
6093 " The worktree at {} was released and branch {branch} deleted.",
6094 path.display()
6095 ),
6096 None => format!(" Branch {branch} was deleted."),
6097 })
6098 }
6099 _ => released.map(|path| {
6100 format!(
6101 " The worktree at {} was released, but branch {branch} was kept.",
6102 path.display()
6103 )
6104 }),
6105 }
6106 }
6107
6108 pub fn merge_pr(&self, number: i64) -> Result<()> {
6112 let n = number.to_string();
6113 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
6114 Ok(_) => Ok(()),
6115 Err(e) => {
6116 if self.pr_state(number) == "MERGED" {
6117 let finished = self
6118 .finish_merged_branch_cleanup(number)
6119 .unwrap_or_default();
6120 logdim!(
6121 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
6122 e.last_line()
6123 );
6124 Ok(())
6125 } else {
6126 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
6127 }
6128 }
6129 };
6130 self.record_write(merged)
6131 }
6132
6133 pub fn merge_pr_at_head(
6135 &self,
6136 number: i64,
6137 expected_head: &str,
6138 delete_branch: bool,
6139 ) -> Result<()> {
6140 let n = number.to_string();
6141 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
6142 Ok(_) => Ok(()),
6143 Err(e) => {
6144 if self.pr_state(number) == "MERGED" {
6145 let finished = self
6146 .finish_merged_branch_cleanup(number)
6147 .unwrap_or_default();
6148 logdim!(
6149 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
6150 e.last_line()
6151 );
6152 Ok(())
6153 } else {
6154 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
6155 }
6156 }
6157 };
6158 self.record_write(merged)
6159 }
6160
6161 pub fn followups_path(&self) -> PathBuf {
6166 self.root.join(STATE_DIR).join("followups.md")
6167 }
6168
6169 pub fn brainstorms_dir(&self) -> PathBuf {
6174 self.root.join(STATE_DIR).join("brainstorms")
6175 }
6176
6177 pub fn worked_followups_path(&self) -> PathBuf {
6185 self.root.join(STATE_DIR).join("followups.done.md")
6186 }
6187
6188 pub fn spend_path(&self) -> PathBuf {
6191 self.root.join(STATE_DIR).join("state").join("spend.json")
6192 }
6193
6194 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
6200 if calls.is_empty() {
6201 return Ok(());
6202 }
6203 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
6204 .ok()
6205 .and_then(|text| serde_json::from_str(&text).ok())
6206 .unwrap_or_default();
6207 all.extend(calls.iter().cloned());
6208 if let Some(parent) = self.spend_path().parent() {
6209 let _ = std::fs::create_dir_all(parent);
6210 }
6211 write_json_atomic(&self.spend_path(), &all)
6212 }
6213
6214 pub fn implementors_path(&self) -> PathBuf {
6220 self.root
6221 .join(STATE_DIR)
6222 .join("state")
6223 .join("implementors.json")
6224 }
6225
6226 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
6227 std::fs::read_to_string(self.implementors_path())
6228 .ok()
6229 .and_then(|text| serde_json::from_str(&text).ok())
6230 .unwrap_or_default()
6231 }
6232
6233 pub fn remember_implementor(&self, issue: i64, agent: &str) {
6236 let mut known = self.read_implementors();
6237 known.insert(issue, agent.to_string());
6238 if let Some(parent) = self.implementors_path().parent() {
6239 let _ = std::fs::create_dir_all(parent);
6240 }
6241 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
6242 logdim!("could not record who implemented #{issue}: {e}");
6243 }
6244 }
6245
6246 pub fn contested_path(&self) -> PathBuf {
6251 self.root
6252 .join(STATE_DIR)
6253 .join("state")
6254 .join("contested.json")
6255 }
6256
6257 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
6259 std::fs::read_to_string(self.contested_path())
6260 .ok()
6261 .and_then(|text| serde_json::from_str(&text).ok())
6262 .unwrap_or_default()
6263 }
6264
6265 pub fn remember_contested(&self, items: &[ContestedItem]) {
6270 if items.is_empty() {
6271 return;
6272 }
6273 let mut known = self.read_contested();
6274 for item in items {
6275 known.insert(
6276 item.issue,
6277 Contested {
6278 title: item.title.clone(),
6279 positions: item.positions.clone(),
6280 reasons: item.reasons.clone(),
6281 },
6282 );
6283 }
6284 if let Some(parent) = self.contested_path().parent() {
6285 let _ = std::fs::create_dir_all(parent);
6286 }
6287 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
6288 logdim!("could not record the contested issues: {e}");
6289 }
6290 }
6291
6292 pub fn forget_contested(&self, numbers: &[i64]) {
6294 let mut known = self.read_contested();
6295 let before = known.len();
6296 known.retain(|number, _| !numbers.contains(number));
6297 if known.len() == before {
6298 return;
6299 }
6300 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
6301 logdim!("could not update the contested issues: {e}");
6302 }
6303 }
6304
6305 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
6306 self.root
6307 .join(STATE_DIR)
6308 .join("state")
6309 .join(format!("checkin-{number}.json"))
6310 }
6311
6312 pub fn answer_state_path(&self, number: i64) -> PathBuf {
6314 self.root
6315 .join(STATE_DIR)
6316 .join("state")
6317 .join(format!("answer-{number}.json"))
6318 }
6319
6320 pub fn answer_notes_path(&self, number: i64) -> PathBuf {
6323 self.root
6324 .join(STATE_DIR)
6325 .join("answers")
6326 .join(format!("issue-{number}.md"))
6327 }
6328
6329 pub fn review_record_path(&self, number: i64) -> PathBuf {
6331 self.root
6332 .join(STATE_DIR)
6333 .join("state")
6334 .join(format!("review-{number}.json"))
6335 }
6336
6337 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
6340 let path = self.review_record_path(number);
6341 let text = std::fs::read_to_string(&path).ok()?;
6342 match serde_json::from_str(&text) {
6343 Ok(record) => Some(record),
6344 Err(e) => {
6345 logdim!("could not read {}: {e}", path.display());
6346 None
6347 }
6348 }
6349 }
6350
6351 pub fn write_review_record(
6352 &self,
6353 number: i64,
6354 record: &crate::model::ReviewRecord,
6355 ) -> Result<()> {
6356 write_json_atomic(&self.review_record_path(number), record)
6357 }
6358
6359 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
6374 let path = self.followups_path();
6375 let heading = format!("## {}", title.trim());
6376 for seen in [&path, &self.worked_followups_path()] {
6377 if let Ok(existing) = std::fs::read_to_string(seen) {
6378 if has_heading(&existing, title) {
6379 logdim!("follow-up already noted: {title}");
6380 return Followup::Covered(format!("note: {}", title.trim()));
6381 }
6382 }
6383 }
6384 if let Some(parent) = path.parent() {
6385 let _ = std::fs::create_dir_all(parent);
6386 }
6387 use std::io::Write;
6388 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
6395 match std::fs::OpenOptions::new()
6396 .create(true)
6397 .append(true)
6398 .open(&path)
6399 {
6400 Ok(mut file) => match file.write_all(entry.as_bytes()) {
6401 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
6402 Err(e) => {
6403 logdim!("could not write {}: {e}", path.display());
6404 Followup::Failed
6405 }
6406 },
6407 Err(e) => {
6408 logdim!("could not write {}: {e}", path.display());
6409 Followup::Failed
6410 }
6411 }
6412 }
6413
6414 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
6419 let path = self.worked_followups_path();
6420 if let Some(parent) = path.parent() {
6421 let _ = std::fs::create_dir_all(parent);
6422 }
6423 use std::io::Write;
6424 let entry = format!(
6425 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
6426 title.trim(),
6427 body.trim()
6428 );
6429 if let Ok(mut file) = std::fs::OpenOptions::new()
6430 .create(true)
6431 .append(true)
6432 .open(&path)
6433 {
6434 let _ = file.write_all(entry.as_bytes());
6435 }
6436 }
6437
6438 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
6447 self.root
6448 .join(STATE_DIR)
6449 .join("reviews")
6450 .join(format!("pr-{number}.md"))
6451 }
6452
6453 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
6459 let path = self.pending_comment_path(number);
6460 if let Some(parent) = path.parent() {
6461 std::fs::create_dir_all(parent)
6462 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6463 }
6464 std::fs::write(&path, text)
6465 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6466 Ok(path)
6467 }
6468
6469 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
6470 std::fs::read_to_string(self.pending_comment_path(number)).ok()
6471 }
6472
6473 pub fn state_path(&self, number: i64) -> PathBuf {
6474 self.root
6475 .join(STATE_DIR)
6476 .join("state")
6477 .join(format!("pr-{number}.json"))
6478 }
6479
6480 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
6481 let path = self.state_path(number);
6482 let text = std::fs::read_to_string(&path).ok()?;
6483 match serde_json::from_str(&text) {
6484 Ok(state) => Some(state),
6485 Err(_) => {
6486 logdim!("could not read {}, starting fresh", path.display());
6487 None
6488 }
6489 }
6490 }
6491
6492 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
6493 if let Some(local) = self.read_local_state(pr.number) {
6494 return Some(local);
6495 }
6496 if self.state_store.writes_pr() {
6497 return self.read_pr_state(pr.number);
6498 }
6499 None
6500 }
6501
6502 pub(crate) fn read_state_for_head(
6503 &self,
6504 pr: &PrView,
6505 actual_head: &str,
6506 ) -> Option<PersistedState> {
6507 let local = self
6508 .state_store
6509 .writes_local()
6510 .then(|| self.read_local_state(pr.number))
6511 .flatten();
6512 let remote = self
6513 .state_store
6514 .writes_pr()
6515 .then(|| self.read_pr_state(pr.number))
6516 .flatten();
6517 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
6518 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
6519 self.remember_checkpoint(pr.number, checkpoint);
6520 }
6521 choose_state_for_head(candidates, actual_head)
6522 }
6523
6524 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
6525 self.try_read_pr_state(number).ok().flatten()
6526 }
6527
6528 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
6529 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
6530 if let Some(state) = parse_state_comment(&body) {
6531 return Ok(Some(state));
6532 }
6533 }
6534 Ok(None)
6535 }
6536
6537 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6538 let remote_state = if self.state_store.writes_pr() {
6539 self.try_read_pr_state(number)
6540 } else {
6541 Ok(None)
6542 };
6543 self.write_state_after_remote_read(number, state, remote_state)
6544 }
6545
6546 fn write_state_after_remote_read(
6547 &self,
6548 number: i64,
6549 state: &PersistedState,
6550 remote_state: Result<Option<PersistedState>>,
6551 ) -> Result<()> {
6552 let remote_checkpoint = if self.state_store.writes_pr() {
6553 self.record_failed_write(remote_state)?
6554 .map(|saved| saved.checkpoint)
6555 .unwrap_or_default()
6556 } else {
6557 0
6558 };
6559 let local_checkpoint = self
6560 .state_store
6561 .writes_local()
6562 .then(|| self.read_local_state(number))
6563 .flatten()
6564 .map(|saved| saved.checkpoint)
6565 .unwrap_or_default();
6566 let mut stamped = state.clone();
6567 stamped.checkpoint = state
6568 .checkpoint
6569 .max(local_checkpoint)
6570 .max(remote_checkpoint)
6571 .max(self.remembered_checkpoint(number))
6572 .saturating_add(1);
6573 self.remember_checkpoint(number, stamped.checkpoint);
6574 if self.state_store.writes_local() {
6575 write_json_atomic(&self.state_path(number), &stamped)?;
6576 }
6577 if self.state_store.writes_pr() {
6578 self.write_pr_state(number, &stamped)?;
6579 }
6580 Ok(())
6581 }
6582
6583 fn remembered_checkpoint(&self, number: i64) -> u64 {
6584 self.checkpoints
6585 .lock()
6586 .unwrap_or_else(std::sync::PoisonError::into_inner)
6587 .get(&number)
6588 .copied()
6589 .unwrap_or_default()
6590 }
6591
6592 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6593 let mut checkpoints = self
6594 .checkpoints
6595 .lock()
6596 .unwrap_or_else(std::sync::PoisonError::into_inner);
6597 let saved = checkpoints.entry(number).or_default();
6598 *saved = (*saved).max(checkpoint);
6599 }
6600
6601 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6602 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6606 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6607 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6608 if let Some(id) = comment_id {
6609 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6610 let field = format!("body={body}");
6611 let written = self
6612 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6613 .map(|_| ());
6614 return self.record_write(written);
6615 }
6616 let written = self
6617 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6618 .map(|_| ());
6619 self.record_write(written)
6620 }
6621
6622 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6630 self.try_issue_comments(number).unwrap_or_default()
6631 }
6632
6633 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6634 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6635 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6636 }
6637
6638 pub fn issue_row(&self, number: i64) -> Result<Value> {
6642 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6643 let text = self
6644 .gh(&["api", &path])
6645 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6646 serde_json::from_str(&text)
6647 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6648 }
6649
6650 pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6653 #[derive(Deserialize)]
6654 struct Row {
6655 number: i64,
6656 }
6657 let name = self
6659 .repo_name()
6660 .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6661 let text = self.gh(&[
6662 "search",
6663 "issues",
6664 "--repo",
6665 &name,
6666 "--mentions",
6667 login,
6668 "--state",
6669 "open",
6670 "--limit",
6671 &FETCH_CEILING.to_string(),
6672 "--json",
6673 "number",
6674 ])?;
6675 let rows: Vec<Row> = serde_json::from_str(text.trim())
6676 .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6677 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6678 numbers.sort_unstable();
6679 numbers.truncate(limit);
6680 Ok(numbers)
6681 }
6682
6683 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6690 let viewer = self.viewer_login()?.to_string();
6691 let mut mine = Vec::new();
6692 let mut theirs = Vec::new();
6693 for comment in self.try_issue_comments(number)? {
6694 let Some(body) = comment.get("body").and_then(Value::as_str) else {
6695 continue;
6696 };
6697 if !body.contains("spar:state") {
6698 continue;
6699 }
6700 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6701 continue;
6702 };
6703 let author = comment
6704 .get("user")
6705 .and_then(|u| u.get("login"))
6706 .and_then(Value::as_str)
6707 .unwrap_or_default();
6708 if is_state_comment(body, author, &viewer) {
6709 mine.push((id, body.to_string()));
6710 } else {
6711 theirs.push(format!("@{author}"));
6712 }
6713 }
6714 if !theirs.is_empty() {
6715 logdim!(
6716 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6717 own state block is patched or deleted.",
6718 theirs.len(),
6719 theirs.join(", ")
6720 );
6721 }
6722 Ok(mine)
6723 }
6724
6725 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6726 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6727 }
6728
6729 pub fn clear_state(&self, number: i64) {
6731 let path = self.state_path(number);
6732 let _ = std::fs::remove_file(&path);
6733 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6734 }
6735
6736 pub fn prune_state(&self) -> Vec<String> {
6743 let base = self.root.join(STATE_DIR).join("state");
6744 let Ok(entries) = std::fs::read_dir(&base) else {
6745 return Vec::new();
6746 };
6747 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6748 for name in entries
6749 .flatten()
6750 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6751 {
6752 if let Some(number) = state_file_number(&name) {
6753 by_number.entry(number).or_default().push(name);
6754 }
6755 }
6756
6757 let mut removed = Vec::new();
6758 for (number, mut names) in by_number {
6759 if !is_finished(&self.pr_state(number)) {
6760 continue;
6761 }
6762 names.sort();
6763 for name in names {
6764 let _ = std::fs::remove_file(base.join(&name));
6765 removed.push(format!("state {name}"));
6766 }
6767 }
6768 removed
6769 }
6770
6771 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6775 #[derive(Deserialize)]
6776 struct Row {
6777 number: i64,
6778 }
6779 let numbers = match numbers {
6780 Some(numbers) => numbers,
6781 None => {
6782 let listed: Result<Vec<i64>> = (|| {
6783 let text = self.gh(&[
6784 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6785 ])?;
6786 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6787 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6788 Ok(rows.into_iter().map(|row| row.number).collect())
6789 })();
6790 match self.record_failed_write(listed) {
6791 Ok(numbers) => numbers,
6792 Err(e) => {
6793 logdim!("could not inspect pull requests for state cleanup: {e}");
6794 return Vec::new();
6795 }
6796 }
6797 }
6798 };
6799
6800 let mut removed = Vec::new();
6801 for number in numbers {
6802 let state = match self.record_failed_write(self.try_pr_state(number)) {
6803 Ok(state) => state,
6804 Err(e) => {
6805 logdim!("could not inspect PR #{number} for state cleanup: {e}");
6806 continue;
6807 }
6808 };
6809 if !is_finished(&state) {
6810 continue;
6811 }
6812 let comments = match self.record_failed_write(self.try_state_comments(number)) {
6813 Ok(comments) => comments,
6814 Err(e) => {
6815 logdim!("could not inspect state comments on PR #{number}: {e}");
6816 continue;
6817 }
6818 };
6819 for (id, _) in comments {
6820 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6821 let deleted = self
6822 .gh(&["api", "-X", "DELETE", &path, "--silent"])
6823 .map(|_| ());
6824 match self.record_write(deleted) {
6825 Ok(()) => removed.push(format!("state comment on PR #{number}")),
6826 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6827 }
6828 }
6829 }
6830 removed
6831 }
6832
6833 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6840 let base = self.root.join(WORKTREE_DIR);
6841 let mut removed = Vec::new();
6842 let known = self.known_branches();
6843
6844 if let Ok(entries) = std::fs::read_dir(&base) {
6845 let mut names: Vec<String> = entries
6846 .flatten()
6847 .filter(|e| e.path().is_dir())
6848 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6849 .collect();
6850 names.sort();
6851
6852 for name in names {
6853 if let Some(owner) = read_checkout_owner(&name) {
6857 let path = base.join(&name);
6858 if force_all {
6859 self.remove_worktree_at_force(&path);
6860 } else if process_is_running(owner) {
6861 continue;
6862 } else if has_recovery_marker(&path) {
6863 logdim!("kept {} for recovery", path.display());
6864 continue;
6865 } else {
6866 self.discard_read_checkout(&path);
6867 }
6868 if !path.exists() {
6869 removed.push(name);
6870 }
6871 continue;
6872 }
6873 if let Some(rest) = name.strip_prefix("review-") {
6876 let number: i64 = rest.parse().unwrap_or(-1);
6877 if !(force_all || is_finished(&self.pr_state(number))) {
6878 continue;
6879 }
6880 let path = base.join(&name);
6881 if force_all {
6882 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6883 if !belongs {
6884 return Ok(false);
6885 }
6886 let local_ref = review_ref(number);
6887 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6888 return Ok(false);
6889 }
6890 let head = self.head_oid_checked(&path)?;
6891 let recorded = self
6892 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6893 .trim()
6894 .to_string();
6895 Ok(head == recorded)
6896 });
6897 match owned {
6898 Ok(true) => {}
6899 Ok(false) => {
6900 logdim!(
6901 "kept {} because no matching SPAR review reference proves \
6902 ownership",
6903 path.display()
6904 );
6905 continue;
6906 }
6907 Err(e) => {
6908 logdim!(
6909 "kept {} because review ownership could not be verified: {}",
6910 path.display(),
6911 e.last_line()
6912 );
6913 continue;
6914 }
6915 }
6916 } else {
6917 if let Err(e) = self.refuse_review_worktree_changes(number) {
6918 logdim!(
6919 "kept {} because its review state could not be verified as \
6920 disposable: {}",
6921 path.display(),
6922 e.last_line()
6923 );
6924 continue;
6925 }
6926 }
6927 if force_all {
6928 if self.remove_worktree_at_force(&path) {
6929 self.git_try(&["update-ref", "-d", &review_ref(number)]);
6930 }
6931 } else {
6932 self.release_review_worktree(number);
6933 }
6934 if !path.exists() {
6935 removed.push(name);
6936 }
6937 continue;
6938 }
6939 let branch = format!("{}{name}", self.branch_prefix);
6940 if !(force_all || self.worktree_is_done(&branch)) {
6941 continue;
6942 }
6943 if !known.contains_key(&branch) {
6944 logdim!("kept {branch} because it has no branch record");
6945 continue;
6946 }
6947 let path = base.join(&name);
6948 if !force_all {
6949 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6950 Ok(true) => {
6951 logdim!(
6952 "kept {} because it contains uncommitted changes or ignored files",
6953 path.display()
6954 );
6955 continue;
6956 }
6957 Err(e) => {
6958 logdim!(
6959 "kept {} because its Git state could not be checked: {}",
6960 path.display(),
6961 e.last_line()
6962 );
6963 continue;
6964 }
6965 Ok(false) => {}
6966 }
6967 match self.branch_deletion_is_safe(&branch) {
6968 Ok(true) => {}
6969 Ok(false) => {
6970 logdim!(
6971 "kept {branch} because no surviving ref preserves its tip or \
6972 reflog-only commits"
6973 );
6974 continue;
6975 }
6976 Err(e) => {
6977 logdim!(
6978 "kept {branch} because preservation could not be verified: {}",
6979 e.last_line()
6980 );
6981 continue;
6982 }
6983 }
6984 }
6985 let removed_worktree = if force_all {
6986 self.remove_worktree_at_force(&path)
6987 } else {
6988 match self.remove_worktree_at(&path) {
6989 Ok(removed) => removed,
6990 Err(error) => {
6991 logdim!(
6992 "kept {branch} and {} because removal did not reach a confirmed \
6993 quiet point: {}",
6994 path.display(),
6995 error.last_line()
6996 );
6997 false
6998 }
6999 }
7000 };
7001 if !removed_worktree {
7002 continue;
7003 }
7004 if force_all {
7005 self.git_try(&["branch", "-D", &branch]);
7006 self.forget_branch(&branch);
7007 } else {
7008 match self.delete_branch_if_safe(&branch) {
7009 Ok(true) => self.forget_branch(&branch),
7010 Ok(false) => logdim!(
7011 "kept {branch} because its tip or reflog changed before deletion"
7012 ),
7013 Err(error) => logdim!(
7014 "kept {branch} because deletion safety could not be rechecked: {}",
7015 error.last_line()
7016 ),
7017 }
7018 }
7019 removed.push(name);
7020 }
7021 }
7022 removed.extend(self.prune_branches(force_all));
7023 removed
7024 }
7025
7026 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
7033 let known = self.known_branches();
7034 let branches: Vec<String> = known.keys().cloned().collect();
7035 if branches.is_empty() {
7036 return Vec::new();
7037 }
7038
7039 let checked_out: Vec<String> = self
7040 .git_try(&["worktree", "list", "--porcelain"])
7041 .lines()
7042 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
7043 .collect();
7044
7045 let existing: Vec<String> = self
7048 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
7049 .lines()
7050 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
7051 .collect();
7052
7053 let mut removed = Vec::new();
7054 for branch in branches {
7055 if !existing.contains(&branch) {
7056 self.forget_branch(&branch); continue;
7058 }
7059 if checked_out.contains(&branch) {
7060 continue;
7061 }
7062 if !(force_all || self.worktree_is_done(&branch)) {
7063 continue;
7064 }
7065 if !force_all {
7066 let Some(_record) = known.get(&branch) else {
7067 continue;
7068 };
7069 match self.branch_deletion_is_safe(&branch) {
7070 Ok(true) => {}
7071 Ok(false) => {
7072 logdim!(
7073 "kept {branch} because no surviving ref preserves its tip or \
7074 reflog-only commits"
7075 );
7076 continue;
7077 }
7078 Err(e) => {
7079 logdim!(
7080 "kept {branch} because preservation could not be verified: {}",
7081 e.last_line()
7082 );
7083 continue;
7084 }
7085 }
7086 }
7087 let deleted = if force_all {
7088 self.git(&["branch", "-D", &branch]).map(|_| true)
7089 } else {
7090 self.delete_branch_if_safe(&branch)
7091 };
7092 match deleted {
7093 Ok(true) => {
7094 self.forget_branch(&branch);
7095 removed.push(format!("branch {branch}"));
7096 }
7097 Ok(false) => {
7098 logdim!("kept {branch} because its tip or reflog changed before deletion");
7099 }
7100 Err(e) => {
7101 logdim!("could not delete {branch}: {}", e.last_line());
7104 }
7105 }
7106 }
7107 removed
7108 }
7109
7110 fn worktree_is_done(&self, branch: &str) -> bool {
7112 #[derive(Deserialize)]
7113 struct Row {
7114 state: String,
7115 }
7116 let entry = branch
7117 .strip_prefix(self.branch_prefix.as_str())
7118 .unwrap_or(branch);
7119 if let Some(rest) = entry.strip_prefix("pr-") {
7120 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
7121 }
7122 if entry.starts_with("issue-") || entry.starts_with("split-") {
7126 let text = self.gh_try(&[
7127 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
7128 ]);
7129 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
7130 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
7131 }
7132 false
7133 }
7134}
7135
7136pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
7148 match probe() {
7149 Ok(value) => Ok(value),
7150 Err(first) => {
7151 logdim!("retrying a worktree probe after: {}", first.last_line());
7152 probe()
7153 }
7154 }
7155}
7156
7157pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
7163 let root = std::fs::canonicalize(cwd)
7164 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7165 let mut files = BTreeMap::new();
7166 let mut visited = BTreeSet::new();
7167 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
7168 Ok(AttributeState { files })
7169}
7170
7171fn collect_attribute_files(
7172 root: &Path,
7173 repository: &Path,
7174 prefix: &Path,
7175 visited: &mut BTreeSet<PathBuf>,
7176 files: &mut BTreeMap<PathBuf, [u8; 32]>,
7177) -> Result<()> {
7178 let canonical = std::fs::canonicalize(repository)
7179 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7180 if !visited.insert(canonical) {
7181 bail!("submodule recursion revisited {}", repository.display());
7182 }
7183 let entries = index_entries(repository)?;
7184 let mut paths: BTreeSet<PathBuf> = entries
7185 .iter()
7186 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7187 .map(|entry| entry.path.clone())
7188 .collect();
7189 paths.extend(untracked_attribute_paths(repository)?);
7195 for path in paths {
7196 let from_root = prefix.join(&path);
7197 let state = attribute_file_fingerprint(&root.join(&from_root))?;
7198 files.insert(from_root, state);
7199 }
7200 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
7201 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
7202 continue;
7203 };
7204 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
7205 }
7206 Ok(())
7207}
7208
7209pub(crate) fn uncertain_worktree_change(
7212 cwd: &Path,
7213 message: impl Into<String>,
7214) -> crate::error::SparError {
7215 let message = message.into();
7216 let marker = write_recovery_marker(cwd, &message);
7217 let note = match marker {
7218 Ok(path) => format!(" Recovery marker: {}.", path.display()),
7219 Err(e) => format!(
7220 " A recovery marker could not be written: {}.",
7221 e.last_line()
7222 ),
7223 };
7224 crate::error::SparError::uncertain_write(format!("{message}{note}"))
7225}
7226
7227fn has_recovery_marker(cwd: &Path) -> bool {
7228 let Ok(entries) = std::fs::read_dir(cwd) else {
7229 return false;
7230 };
7231 entries.flatten().any(|entry| {
7232 entry
7233 .file_name()
7234 .to_str()
7235 .is_some_and(|name| name.starts_with(RECOVERY_MARKER_PREFIX))
7236 })
7237}
7238
7239fn read_checkout_owner(name: &str) -> Option<u32> {
7241 let rest = name.strip_prefix(READ_CHECKOUT_PREFIX)?;
7242 let (pid, serial) = rest.split_once('-')?;
7243 serial.parse::<usize>().ok()?;
7244 pid.parse().ok()
7245}
7246
7247#[cfg(unix)]
7253fn process_is_running(pid: u32) -> bool {
7254 let Ok(pid) = libc::pid_t::try_from(pid) else {
7255 return true;
7256 };
7257 if pid <= 0 {
7258 return true;
7259 }
7260 if unsafe { libc::kill(pid, 0) } == 0 {
7261 return true;
7262 }
7263 std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
7264}
7265
7266#[cfg(not(unix))]
7267fn process_is_running(_pid: u32) -> bool {
7268 true
7269}
7270
7271fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
7272 use std::sync::atomic::{AtomicU32, Ordering};
7273 static NEXT: AtomicU32 = AtomicU32::new(0);
7274 for _ in 0..1000 {
7275 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
7276 let path = cwd.join(format!(
7277 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
7278 std::process::id()
7279 ));
7280 let mut options = OpenOptions::new();
7281 options.write(true).create_new(true);
7282 #[cfg(unix)]
7283 {
7284 use std::os::unix::fs::OpenOptionsExt;
7285 options.mode(0o600);
7286 }
7287 match options.open(&path) {
7288 Ok(mut file) => {
7289 file.write_all(detail.as_bytes())
7290 .and_then(|_| file.write_all(b"\n"))
7291 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
7292 return Ok(path);
7293 }
7294 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
7295 Err(e) => {
7296 return Err(spar_err!(
7297 "could not create a recovery marker in {}: {e}",
7298 cwd.display()
7299 ))
7300 }
7301 }
7302 }
7303 bail!(
7304 "could not choose a free recovery marker name in {}",
7305 cwd.display()
7306 )
7307}
7308
7309fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
7314 let mut argv = vec![
7315 "git".to_string(),
7316 "-c".to_string(),
7317 "maintenance.auto=false".to_string(),
7318 "-c".to_string(),
7319 "gc.auto=0".to_string(),
7320 ];
7321 argv.extend(args.iter().map(|arg| (*arg).to_string()));
7322 argv
7323}
7324
7325fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
7326 let mut argv = git_without_maintenance_argv(&[]);
7327 argv.extend([
7328 "-c".to_string(),
7329 "core.fsmonitor=".to_string(),
7330 "-c".to_string(),
7331 "commit.gpgsign=false".to_string(),
7332 "-c".to_string(),
7333 "core.hooksPath=/dev/null".to_string(),
7334 ]);
7335 argv.extend(args.iter().map(|arg| (*arg).to_string()));
7336 argv
7337}
7338
7339pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
7346 let root = std::fs::canonicalize(cwd)
7347 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7348 let mut files = BTreeMap::new();
7349 let mut ignored = BTreeSet::new();
7350 let mut visited = BTreeSet::new();
7351 collect_untracked_files(
7352 &root,
7353 &root,
7354 Path::new(""),
7355 &mut visited,
7356 &mut files,
7357 &mut ignored,
7358 )?;
7359 Ok(IgnoredState { files, ignored })
7360}
7361
7362fn collect_untracked_files(
7363 root: &Path,
7364 repository: &Path,
7365 prefix: &Path,
7366 visited: &mut BTreeSet<PathBuf>,
7367 files: &mut BTreeMap<PathBuf, UntrackedFile>,
7368 ignored: &mut BTreeSet<PathBuf>,
7369) -> Result<()> {
7370 let canonical = std::fs::canonicalize(repository)
7371 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7372 if !visited.insert(canonical.clone()) {
7373 bail!("submodule recursion revisited {}", canonical.display());
7374 }
7375 let listed = run_git_bytes(
7379 repository,
7380 &["ls-files", "--others", "--exclude-standard", "-z"],
7381 )?;
7382 if !listed.is_empty() && !listed.ends_with(&[0]) {
7383 bail!(
7384 "git returned an unterminated untracked-file list for {}",
7385 repository.display()
7386 );
7387 }
7388
7389 for raw in listed
7390 .split(|byte| *byte == 0)
7391 .filter(|raw| !raw.is_empty())
7392 {
7393 let (relative, nested) = untracked_record(raw, "untracked")?;
7394 let from_root = prefix.join(&relative);
7395 if is_spar_own_path(&from_root) {
7396 continue;
7397 }
7398 let absolute = root.join(&from_root);
7399 let fingerprint = if nested {
7400 nested_repository_fingerprint(&absolute)?
7401 } else {
7402 ignored_file_fingerprint(&absolute)?
7403 };
7404 if files.insert(from_root.clone(), fingerprint).is_some() {
7405 bail!(
7406 "git returned the untracked path more than once: {:?}",
7407 from_root
7408 );
7409 }
7410 }
7411
7412 let ignored_listed = run_git_bytes(
7413 repository,
7414 &[
7415 "ls-files",
7416 "--others",
7417 "--ignored",
7418 "--exclude-standard",
7419 "-z",
7420 ],
7421 )?;
7422 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
7423 bail!(
7424 "git returned an unterminated ignored-file list for {}",
7425 repository.display()
7426 );
7427 }
7428 for raw in ignored_listed
7434 .split(|byte| *byte == 0)
7435 .filter(|raw| !raw.is_empty())
7436 {
7437 let (relative, nested) = untracked_record(raw, "ignored")?;
7438 let from_root = prefix.join(relative);
7439 if is_spar_own_path(&from_root) {
7440 continue;
7441 }
7442 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
7443 let absolute = root.join(&from_root);
7444 let fingerprint = if nested {
7445 nested_repository_fingerprint(&absolute)?
7446 } else {
7447 ignored_file_fingerprint(&absolute)?
7448 };
7449 slot.insert(fingerprint);
7450 }
7451 if !ignored.insert(from_root.clone()) {
7452 bail!(
7453 "git returned the ignored path more than once: {:?}",
7454 from_root
7455 );
7456 }
7457 }
7458
7459 for link in gitlinks(repository)? {
7460 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7461 continue;
7462 };
7463 collect_untracked_files(
7464 root,
7465 &submodule,
7466 &prefix.join(&link.path),
7467 visited,
7468 files,
7469 ignored,
7470 )?;
7471 }
7472 Ok(())
7473}
7474
7475fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
7476 let argv = git_without_automation_argv(args);
7477 proc::run_bytes(
7478 &argv,
7479 &ExecOpts::new()
7480 .cwd(cwd)
7481 .timeout_secs(30)
7482 .stop_descendants(true),
7483 )
7484}
7485
7486fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
7487 let argv = git_without_automation_argv(args);
7488 proc::run(
7489 &argv,
7490 &ExecOpts::new()
7491 .cwd(cwd)
7492 .timeout_secs(30)
7493 .stop_descendants(true),
7494 )
7495}
7496
7497fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
7498 let path = path.to_str().ok_or_else(|| {
7499 spar_err!(
7500 "cannot verify filtered content for a non-UTF-8 path in {}",
7501 cwd.display()
7502 )
7503 })?;
7504 let path_arg = format!("--path={path}");
7505 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
7506 Ok(Sha256::digest(bytes).into())
7507}
7508
7509fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
7510 let relative = path_from_git_bytes(raw)?;
7511 if relative.is_absolute()
7512 || relative.components().any(|component| {
7513 matches!(
7514 component,
7515 std::path::Component::ParentDir
7516 | std::path::Component::RootDir
7517 | std::path::Component::Prefix(_)
7518 )
7519 })
7520 {
7521 bail!("git returned an unsafe {kind} path: {:?}", relative);
7522 }
7523 Ok(relative)
7524}
7525
7526fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
7535 let nested = raw.last() == Some(&b'/');
7536 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
7537 if trimmed.is_empty() {
7538 bail!("git returned an empty {kind} path");
7539 }
7540 Ok((safe_git_path(trimmed, kind)?, nested))
7541}
7542
7543fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
7544 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
7545 if !listed.is_empty() && !listed.ends_with(&[0]) {
7546 bail!(
7547 "git returned an unterminated index listing for {}",
7548 cwd.display()
7549 );
7550 }
7551 let mut entries = Vec::new();
7552 for record in listed
7553 .split(|byte| *byte == 0)
7554 .filter(|record| !record.is_empty())
7555 {
7556 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7557 bail!(
7558 "git returned a malformed index record for {}",
7559 cwd.display()
7560 );
7561 };
7562 let header = &record[..tab];
7563 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
7564 if fields.len() != 3 {
7565 bail!(
7566 "git returned a malformed index header for {}",
7567 cwd.display()
7568 );
7569 }
7570 if fields[2] != b"0" {
7571 continue;
7572 }
7573 let mode = std::str::from_utf8(fields[0])
7574 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
7575 .to_string();
7576 let oid = std::str::from_utf8(fields[1])
7577 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7578 .to_string();
7579 entries.push(IndexEntry {
7580 path: safe_git_path(&record[tab + 1..], "index")?,
7581 mode,
7582 oid,
7583 });
7584 }
7585 Ok(entries)
7586}
7587
7588fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
7590 let listed = run_git_bytes(
7591 cwd,
7592 &[
7593 "ls-files",
7594 "--others",
7595 "--exclude-standard",
7596 "-z",
7597 "--",
7598 ".gitattributes",
7599 ":(glob)**/.gitattributes",
7600 ],
7601 )?;
7602 if !listed.is_empty() && !listed.ends_with(&[0]) {
7603 bail!(
7604 "git returned an unterminated attribute-file listing for {}",
7605 cwd.display()
7606 );
7607 }
7608 listed
7609 .split(|byte| *byte == 0)
7610 .filter(|record| !record.is_empty())
7611 .map(|raw| safe_git_path(raw, "attribute"))
7612 .collect()
7613}
7614
7615fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
7616 if !untracked_attribute_paths(cwd)?.is_empty() {
7617 return Ok(true);
7618 }
7619
7620 let index = index_entries(cwd)?
7621 .into_iter()
7622 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7623 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7624 .collect::<BTreeMap<_, _>>();
7625 let head = tree_entries(cwd, "HEAD")?
7626 .into_iter()
7627 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7628 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7629 .collect::<BTreeMap<_, _>>();
7630 if index != head {
7631 return Ok(true);
7632 }
7633
7634 let effective = check_attributes(cwd, index.keys().cloned())?;
7635 let config = CheckoutConfig::read(cwd)?;
7636 for (path, (_mode, oid)) in index {
7637 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
7638 return Ok(true);
7639 };
7640 let attributes = effective
7641 .get(&path)
7642 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
7643 if allows_expected_crlf(&config, attributes)? {
7644 if worktree.mode == "120000" {
7645 return Ok(true);
7646 }
7647 let (normalized, every_lf_was_crlf) =
7648 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7649 if !every_lf_was_crlf || normalized != oid {
7650 return Ok(true);
7651 }
7652 } else if worktree.raw_oid != oid {
7653 return Ok(true);
7654 }
7655 }
7656 Ok(false)
7657}
7658
7659fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7660 Ok(index_entries(cwd)?
7661 .into_iter()
7662 .filter(|entry| entry.mode == "160000")
7663 .map(|entry| Gitlink {
7664 path: entry.path,
7665 oid: entry.oid,
7666 })
7667 .collect())
7668}
7669
7670fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7671 let mut tracked = BTreeMap::new();
7672 for entry in index_entries(cwd)? {
7673 if entry.mode == "160000" {
7674 continue;
7675 }
7676 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7677 tracked.insert(
7678 entry.path,
7679 TrackedEntry {
7680 index_mode: entry.mode,
7681 index_oid: entry.oid,
7682 worktree,
7683 },
7684 );
7685 }
7686 Ok(tracked)
7687}
7688
7689fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7690 let metadata = match std::fs::symlink_metadata(path) {
7691 Ok(metadata) => metadata,
7692 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7693 Err(e) => {
7694 return Err(spar_err!(
7695 "could not inspect tracked file {}: {e}",
7696 path.display()
7697 ))
7698 }
7699 };
7700 let mut fingerprint = Sha256::new();
7701 if metadata.file_type().is_symlink() {
7702 let target = std::fs::read_link(path)
7703 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7704 let bytes = os_str_bytes(target.as_os_str())?;
7705 fingerprint.update(b"symlink\0");
7706 fingerprint.update(&bytes);
7707 let content = Sha256::digest(&bytes).into();
7708 return Ok(Some(WorktreeFile {
7709 mode: "120000".to_string(),
7710 #[cfg(unix)]
7711 permissions: 0,
7712 raw_oid: git_blob_oid(oid_len, &bytes)?,
7713 fingerprint: fingerprint.finalize().into(),
7714 content,
7715 }));
7716 }
7717 if !metadata.is_file() {
7718 bail!("tracked path {} is not a file or symlink", path.display());
7719 }
7720
7721 let mut options = OpenOptions::new();
7722 options.read(true);
7723 #[cfg(unix)]
7724 {
7725 use std::os::unix::fs::OpenOptionsExt;
7726 options.custom_flags(libc::O_NOFOLLOW);
7727 }
7728 let mut file = options
7729 .open(path)
7730 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7731 let before = file
7732 .metadata()
7733 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7734 let mode = tracked_file_mode(&before);
7735 #[cfg(unix)]
7736 let permissions = {
7737 use std::os::unix::fs::MetadataExt;
7738 before.mode() & 0o7777
7739 };
7740 fingerprint.update(b"file\0");
7741 fingerprint.update(mode.as_bytes());
7742 #[cfg(unix)]
7743 fingerprint.update(permissions.to_le_bytes());
7744 fingerprint.update(before.len().to_le_bytes());
7745 let mut content = Sha256::new();
7746 let header = format!("blob {}\0", before.len());
7747 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7748 let mut buf = [0u8; 64 * 1024];
7749 loop {
7750 let read = file
7751 .read(&mut buf)
7752 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7753 if read == 0 {
7754 break;
7755 }
7756 fingerprint.update(&buf[..read]);
7757 content.update(&buf[..read]);
7758 object.update(&buf[..read]);
7759 }
7760 let after = file
7761 .metadata()
7762 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7763 if before.len() != after.len()
7764 || before.modified().ok() != after.modified().ok()
7765 || before.permissions() != after.permissions()
7766 {
7767 bail!(
7768 "tracked file {} changed while it was being inspected",
7769 path.display()
7770 );
7771 }
7772 let current = std::fs::symlink_metadata(path)
7773 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7774 if !same_file(&after, ¤t) {
7775 bail!(
7776 "tracked file {} was replaced while it was being inspected",
7777 path.display()
7778 );
7779 }
7780 Ok(Some(WorktreeFile {
7781 mode,
7782 #[cfg(unix)]
7783 permissions,
7784 raw_oid: object.finish(),
7785 fingerprint: fingerprint.finalize().into(),
7786 content: content.finalize().into(),
7787 }))
7788}
7789
7790fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7791 let metadata = std::fs::symlink_metadata(path)
7792 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7793 let mut digest = Sha256::new();
7794 if metadata.file_type().is_symlink() {
7795 digest.update(b"symlink\0");
7796 let target = std::fs::read_link(path)
7797 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7798 digest.update(os_str_bytes(target.as_os_str())?);
7799 return Ok(digest.finalize().into());
7800 }
7801 if !metadata.is_file() {
7802 bail!("attribute path {} is not a file or symlink", path.display());
7803 }
7804 let mut options = OpenOptions::new();
7805 options.read(true);
7806 #[cfg(unix)]
7807 {
7808 use std::os::unix::fs::OpenOptionsExt;
7809 options.custom_flags(libc::O_NOFOLLOW);
7810 }
7811 let mut file = options
7812 .open(path)
7813 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7814 let before = file
7815 .metadata()
7816 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7817 digest.update(b"file\0");
7818 let mut buf = [0u8; 64 * 1024];
7819 loop {
7820 let read = file
7821 .read(&mut buf)
7822 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7823 if read == 0 {
7824 break;
7825 }
7826 digest.update(&buf[..read]);
7827 }
7828 let after = file
7829 .metadata()
7830 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7831 let current = std::fs::symlink_metadata(path)
7832 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7833 if before.len() != after.len()
7834 || before.modified().ok() != after.modified().ok()
7835 || !same_file(&after, ¤t)
7836 {
7837 bail!(
7838 "attribute file {} changed while it was being inspected",
7839 path.display()
7840 );
7841 }
7842 Ok(digest.finalize().into())
7843}
7844
7845enum ObjectHasher {
7846 Sha1(Sha1),
7847 Sha256(Sha256),
7848}
7849
7850impl ObjectHasher {
7851 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7852 let mut hasher = match oid_len {
7853 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7854 64 => Self::Sha256(Sha256::new()),
7855 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7856 };
7857 hasher.update(header);
7858 Ok(hasher)
7859 }
7860
7861 fn update(&mut self, bytes: &[u8]) {
7862 match self {
7863 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7864 Self::Sha256(hasher) => hasher.update(bytes),
7865 }
7866 }
7867
7868 fn finish(self) -> String {
7869 let bytes = match self {
7870 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7871 Self::Sha256(hasher) => hasher.finalize().to_vec(),
7872 };
7873 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7874 }
7875}
7876
7877fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7878 let header = format!("blob {}\0", bytes.len());
7879 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7880 hasher.update(bytes);
7881 Ok(hasher.finish())
7882}
7883
7884fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7885 let mut first = open_regular_file(path)?;
7886 let first_before = first
7887 .metadata()
7888 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7889 let mut raw_len = 0u64;
7890 let mut crlf_pairs = 0u64;
7891 let mut previous_was_cr = false;
7892 let mut every_lf_was_crlf = true;
7893 let mut buf = [0u8; 64 * 1024];
7894 loop {
7895 let read = first
7896 .read(&mut buf)
7897 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7898 if read == 0 {
7899 break;
7900 }
7901 raw_len = raw_len
7902 .checked_add(read as u64)
7903 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7904 for byte in &buf[..read] {
7905 if *byte == b'\n' {
7906 if previous_was_cr {
7907 crlf_pairs += 1;
7908 } else {
7909 every_lf_was_crlf = false;
7910 }
7911 }
7912 previous_was_cr = *byte == b'\r';
7913 }
7914 }
7915 let first_after = first
7916 .metadata()
7917 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7918 let current = std::fs::symlink_metadata(path)
7919 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7920 if raw_len != first_before.len()
7921 || !stable_file_metadata(&first_before, &first_after)
7922 || !stable_file_metadata(&first_after, ¤t)
7923 {
7924 bail!(
7925 "tracked file {} changed while line endings were inspected",
7926 path.display()
7927 );
7928 }
7929
7930 let normalized_len = raw_len
7931 .checked_sub(crlf_pairs)
7932 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7933 let header = format!("blob {normalized_len}\0");
7934 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7935 let mut second = open_regular_file(path)?;
7936 let second_before = second
7937 .metadata()
7938 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7939 if !stable_file_metadata(&first_after, &second_before) {
7940 bail!(
7941 "tracked file {} changed between line-ending checks",
7942 path.display()
7943 );
7944 }
7945 let mut pending_cr = false;
7946 loop {
7947 let read = second
7948 .read(&mut buf)
7949 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7950 if read == 0 {
7951 break;
7952 }
7953 for byte in &buf[..read] {
7954 if pending_cr {
7955 if *byte == b'\n' {
7956 object.update(b"\n");
7957 pending_cr = false;
7958 continue;
7959 }
7960 object.update(b"\r");
7961 pending_cr = false;
7962 }
7963 if *byte == b'\r' {
7964 pending_cr = true;
7965 } else {
7966 object.update(std::slice::from_ref(byte));
7967 }
7968 }
7969 }
7970 if pending_cr {
7971 object.update(b"\r");
7972 }
7973 let second_after = second
7974 .metadata()
7975 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7976 let current = std::fs::symlink_metadata(path)
7977 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7978 if !stable_file_metadata(&second_before, &second_after)
7979 || !stable_file_metadata(&second_after, ¤t)
7980 {
7981 bail!(
7982 "tracked file {} changed while line endings were hashed",
7983 path.display()
7984 );
7985 }
7986 Ok((object.finish(), every_lf_was_crlf))
7987}
7988
7989fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7990 let mut options = OpenOptions::new();
7991 options.read(true);
7992 #[cfg(unix)]
7993 {
7994 use std::os::unix::fs::OpenOptionsExt;
7995 options.custom_flags(libc::O_NOFOLLOW);
7996 }
7997 let file = options
7998 .open(path)
7999 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
8000 let metadata = file
8001 .metadata()
8002 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
8003 if !metadata.is_file() {
8004 bail!("tracked path {} is not a regular file", path.display());
8005 }
8006 Ok(file)
8007}
8008
8009fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8010 if !same_file(left, right)
8011 || left.len() != right.len()
8012 || left.modified().ok() != right.modified().ok()
8013 || left.permissions() != right.permissions()
8014 {
8015 return false;
8016 }
8017 #[cfg(unix)]
8018 {
8019 use std::os::unix::fs::MetadataExt;
8020 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
8021 }
8022 #[cfg(not(unix))]
8023 {
8024 left.created().ok() == right.created().ok()
8025 }
8026}
8027
8028const ATTRIBUTE_BATCH: usize = 1000;
8035
8036fn check_attributes(
8037 cwd: &Path,
8038 paths: impl IntoIterator<Item = PathBuf>,
8039) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
8040 let paths = paths.into_iter().collect::<BTreeSet<_>>();
8041 if paths.is_empty() {
8042 return Ok(BTreeMap::new());
8043 }
8044 let ordered = paths.into_iter().collect::<Vec<_>>();
8045 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
8046 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
8047 values.extend(check_attribute_batch(cwd, batch)?);
8048 }
8049 Ok(values)
8050}
8051
8052fn check_attribute_batch(
8053 cwd: &Path,
8054 batch: &[PathBuf],
8055) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
8056 const NAMES: [&str; 6] = [
8057 "filter",
8058 "working-tree-encoding",
8059 "ident",
8060 "text",
8061 "eol",
8062 "crlf",
8063 ];
8064 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
8065 let mut input = String::new();
8066 for path in &paths {
8067 let path = path.to_str().ok_or_else(|| {
8068 spar_err!(
8069 "cannot inspect attributes for a non-UTF-8 path in {}",
8070 cwd.display()
8071 )
8072 })?;
8073 input.push_str(path);
8074 input.push('\0');
8075 }
8076 let argv = git_without_automation_argv(&[
8077 "check-attr",
8078 "-z",
8079 "--cached",
8080 "--stdin",
8081 "filter",
8082 "working-tree-encoding",
8083 "ident",
8084 "text",
8085 "eol",
8086 "crlf",
8087 ]);
8088 let output = proc::run_bytes(
8089 &argv,
8090 &ExecOpts::new()
8091 .cwd(cwd)
8092 .timeout_secs(30)
8093 .stdin(input)
8094 .stop_descendants(true),
8095 )?;
8096 if !output.is_empty() && !output.ends_with(&[0]) {
8097 bail!(
8098 "git returned an unterminated attribute result for {}",
8099 cwd.display()
8100 );
8101 }
8102 let fields = output
8103 .split(|byte| *byte == 0)
8104 .filter(|field| !field.is_empty())
8105 .collect::<Vec<_>>();
8106 if fields.len() != paths.len() * NAMES.len() * 3 {
8107 bail!(
8108 "git returned an unexpected attribute result for {}",
8109 cwd.display()
8110 );
8111 }
8112 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
8113 for record in fields.chunks_exact(3) {
8114 let path = safe_git_path(record[0], "attribute")?;
8115 if !paths.contains(&path) {
8116 bail!(
8117 "git returned attributes for the wrong path in {}",
8118 cwd.display()
8119 );
8120 }
8121 let name = std::str::from_utf8(record[1])
8122 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
8123 let value = std::str::from_utf8(record[2])
8124 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
8125 values
8126 .entry(path)
8127 .or_default()
8128 .insert(name.to_string(), value.to_string());
8129 }
8130 if paths.iter().any(|path| {
8131 values
8132 .get(path)
8133 .is_none_or(|attributes| attributes.len() != NAMES.len())
8134 }) {
8135 bail!(
8136 "git omitted an attribute result for a tracked path in {}",
8137 cwd.display()
8138 );
8139 }
8140 Ok(values)
8141}
8142
8143fn attribute_is_active(value: Option<&String>) -> bool {
8144 !matches!(
8145 value.map(String::as_str),
8146 None | Some("unspecified") | Some("unset")
8147 )
8148}
8149
8150fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
8151 attribute_is_active(values.get("filter"))
8152 || attribute_is_active(values.get("working-tree-encoding"))
8153}
8154
8155struct CheckoutConfig {
8164 autocrlf: Option<String>,
8165 eol: Option<String>,
8166 symlinks: Option<bool>,
8167}
8168
8169impl CheckoutConfig {
8170 fn read(cwd: &Path) -> Result<Self> {
8171 Ok(Self {
8172 autocrlf: git_config_value(cwd, "core.autocrlf")?,
8173 eol: git_config_value(cwd, "core.eol")?,
8174 symlinks: git_config_bool(cwd, "core.symlinks")?,
8175 })
8176 }
8177
8178 fn autocrlf_is_true(&self) -> bool {
8179 self.autocrlf.as_ref().is_some_and(|value| {
8180 matches!(
8181 value.to_ascii_lowercase().as_str(),
8182 "true" | "yes" | "on" | "1"
8183 )
8184 })
8185 }
8186
8187 fn eol_is(&self, wanted: &str) -> bool {
8188 self.eol
8189 .as_ref()
8190 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
8191 }
8192}
8193
8194fn path_has_ambiguous_transform(
8195 config: &CheckoutConfig,
8196 values: &BTreeMap<String, String>,
8197) -> Result<bool> {
8198 if path_has_external_transform(values)
8199 || attribute_is_active(values.get("ident"))
8200 || attribute_is_active(values.get("crlf"))
8201 {
8202 return Ok(true);
8203 }
8204 let text = values.get("text").map(String::as_str);
8205 let eol = values.get("eol").map(String::as_str);
8206 if text == Some("auto") {
8207 return Ok(true);
8208 }
8209 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
8210 || !matches!(
8211 eol,
8212 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
8213 )
8214 {
8215 return Ok(true);
8216 }
8217 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
8218 return Ok(config.autocrlf_is_true());
8219 }
8220 Ok(false)
8221}
8222
8223fn allows_expected_crlf(
8224 config: &CheckoutConfig,
8225 values: &BTreeMap<String, String>,
8226) -> Result<bool> {
8227 if path_has_external_transform(values)
8228 || attribute_is_active(values.get("ident"))
8229 || attribute_is_active(values.get("crlf"))
8230 {
8231 return Ok(false);
8232 }
8233 let text = values.get("text").map(String::as_str);
8234 let eol = values.get("eol").map(String::as_str);
8235 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
8236 return Ok(false);
8237 }
8238 if eol == Some("crlf") {
8239 return Ok(true);
8240 }
8241 if text != Some("set") {
8242 return Ok(false);
8243 }
8244 if let Some(autocrlf) = config.autocrlf.as_deref() {
8245 match autocrlf.to_ascii_lowercase().as_str() {
8246 "true" | "yes" | "on" | "1" => return Ok(true),
8247 "input" => return Ok(false),
8248 _ => {}
8249 }
8250 }
8251 if config.eol_is("crlf") {
8252 return Ok(true);
8253 }
8254 #[cfg(windows)]
8255 if config.eol.is_none() || config.eol_is("native") {
8256 return Ok(true);
8257 }
8258 Ok(false)
8259}
8260
8261fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
8262 let argv = git_without_automation_argv(&["config", "--get", key]);
8263 let output = proc::exec(
8264 &argv,
8265 &ExecOpts::new()
8266 .cwd(cwd)
8267 .timeout_secs(30)
8268 .check(false)
8269 .stop_descendants(true),
8270 )?;
8271 match output.code {
8272 0 => Ok(Some(output.stdout.trim().to_string())),
8273 1 => Ok(None),
8274 _ => bail!(
8275 "could not read Git configuration in {}: {}",
8276 cwd.display(),
8277 output.stderr.trim()
8278 ),
8279 }
8280}
8281
8282fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
8283 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
8284 let output = proc::exec(
8285 &argv,
8286 &ExecOpts::new()
8287 .cwd(cwd)
8288 .timeout_secs(30)
8289 .check(false)
8290 .stop_descendants(true),
8291 )?;
8292 match output.code {
8293 0 if output.stdout.trim() == "true" => Ok(Some(true)),
8294 0 if output.stdout.trim() == "false" => Ok(Some(false)),
8295 0 => bail!(
8296 "git returned an invalid boolean for {key} in {}",
8297 cwd.display()
8298 ),
8299 1 => Ok(None),
8300 _ => bail!(
8301 "could not read Git configuration in {}: {}",
8302 cwd.display(),
8303 output.stderr.trim()
8304 ),
8305 }
8306}
8307
8308#[cfg(unix)]
8309fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
8310 use std::os::unix::fs::PermissionsExt;
8311 if metadata.permissions().mode() & 0o111 == 0 {
8312 "100644".to_string()
8313 } else {
8314 "100755".to_string()
8315 }
8316}
8317
8318#[cfg(not(unix))]
8319fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
8320 "100644".to_string()
8321}
8322
8323fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
8324 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
8325 if !listed.is_empty() && !listed.ends_with(&[0]) {
8326 bail!(
8327 "git returned an unterminated tree listing for {}",
8328 cwd.display()
8329 );
8330 }
8331 let mut entries = Vec::new();
8332 for record in listed
8333 .split(|byte| *byte == 0)
8334 .filter(|record| !record.is_empty())
8335 {
8336 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
8337 bail!("git returned a malformed tree record for {}", cwd.display());
8338 };
8339 let fields = record[..tab]
8340 .split(|byte| *byte == b' ')
8341 .collect::<Vec<_>>();
8342 if fields.len() != 3 {
8343 bail!("git returned a malformed tree header for {}", cwd.display());
8344 }
8345 let mode = std::str::from_utf8(fields[0])
8346 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
8347 .to_string();
8348 let oid = std::str::from_utf8(fields[2])
8349 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
8350 .to_string();
8351 entries.push(IndexEntry {
8352 path: safe_git_path(&record[tab + 1..], "tree")?,
8353 mode,
8354 oid,
8355 });
8356 }
8357 Ok(entries)
8358}
8359
8360fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
8361 Ok(tree_entries(cwd, "HEAD")?
8362 .into_iter()
8363 .filter(|entry| entry.mode == "160000")
8364 .map(|entry| (entry.path, entry.oid))
8365 .collect())
8366}
8367
8368fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
8369 let head = head_gitlinks(cwd)?;
8370 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
8371 .into_iter()
8372 .map(|link| (link.path, link.oid))
8373 .collect();
8374 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
8375 paths.extend(index.keys().cloned());
8376 Ok(paths
8377 .into_iter()
8378 .filter(|path| head.get(path) != index.get(path))
8379 .collect())
8380}
8381
8382fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
8383 let path = parent.join(relative);
8384 let metadata = match std::fs::symlink_metadata(&path) {
8385 Ok(metadata) => metadata,
8386 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8387 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8388 };
8389 if !metadata.is_dir() {
8390 bail!("the gitlink at {} is not a directory", path.display());
8391 }
8392 let canonical = std::fs::canonicalize(&path)
8393 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
8394 if canonical != path {
8395 bail!(
8396 "the gitlink at {} resolves through a symlink",
8397 path.display()
8398 );
8399 }
8400 if !path.join(".git").exists() {
8401 let empty = std::fs::read_dir(&path)
8402 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
8403 .next()
8404 .is_none();
8405 if empty {
8406 return Ok(None);
8407 }
8408 bail!(
8409 "the uninitialized gitlink at {} contains local files",
8410 path.display()
8411 );
8412 }
8413 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
8414 if inside.trim() != "true" {
8415 bail!("the gitlink at {} is not a worktree", path.display());
8416 }
8417 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
8418 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
8419 spar_err!(
8420 "could not resolve the gitlink top level at {}: {e}",
8421 path.display()
8422 )
8423 })?;
8424 if top != canonical {
8425 bail!(
8426 "the gitlink at {} belongs to a different worktree",
8427 path.display()
8428 );
8429 }
8430 Ok(Some(canonical))
8431}
8432
8433fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
8447 let root = std::fs::canonicalize(cwd)
8448 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8449 let mut allowed = BTreeSet::from([root.join(".git")]);
8450 let mut checkouts = BTreeSet::new();
8451 let mut repositories = vec![root.clone()];
8452 let mut visited = BTreeSet::new();
8453 while let Some(repository) = repositories.pop() {
8454 let canonical = std::fs::canonicalize(&repository)
8455 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8456 if !visited.insert(canonical.clone()) {
8457 bail!("submodule recursion revisited {}", canonical.display());
8458 }
8459 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
8460 let (path, nested) = untracked_record(&raw, "untracked")?;
8461 if nested {
8462 checkouts.insert(canonical.join(path));
8463 }
8464 }
8465 for link in gitlinks(&canonical)? {
8466 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
8467 continue;
8468 };
8469 allowed.insert(submodule.join(".git"));
8470 repositories.push(submodule);
8471 }
8472 }
8473
8474 let scan_root = root.clone();
8478 let mut directories = vec![root];
8479 while let Some(directory) = directories.pop() {
8480 let entries = match std::fs::read_dir(&directory) {
8481 Ok(entries) => entries,
8482 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8483 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8484 };
8485 for entry in entries {
8486 let entry = match entry {
8487 Ok(entry) => entry,
8488 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8489 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8490 };
8491 let path = entry.path();
8492 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
8493 continue;
8494 }
8495 if entry.file_name() == OsStr::new(".git") {
8496 if !allowed.contains(&path) {
8497 return Ok(Some(path));
8498 }
8499 continue;
8500 }
8501 let kind = match entry.file_type() {
8502 Ok(kind) => kind,
8503 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8504 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8505 };
8506 if kind.is_dir() && !checkouts.contains(&path) {
8507 directories.push(path);
8508 }
8509 }
8510 }
8511 Ok(None)
8512}
8513
8514pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
8515 if let Some(path) = unexpected_nested_git_entry(cwd)? {
8516 bail!(
8517 "the worktree contains a Git entry at {} that Git does not report as a nested \
8518 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
8519 prove they are unchanged.",
8520 path.display()
8521 );
8522 }
8523 let root = std::fs::canonicalize(cwd)
8524 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8525 let mut repositories = BTreeMap::new();
8526 let mut visited = BTreeSet::new();
8527 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
8528 Ok(GitState { repositories })
8529}
8530
8531fn collect_git_state(
8532 repository: &Path,
8533 prefix: &Path,
8534 visited: &mut BTreeSet<PathBuf>,
8535 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
8536) -> Result<()> {
8537 let canonical = std::fs::canonicalize(repository)
8538 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8539 if !visited.insert(canonical.clone()) {
8540 bail!("submodule recursion revisited {}", canonical.display());
8541 }
8542 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8543 let head = head.trim().to_string();
8544 if head.is_empty() {
8545 bail!("git returned an empty head for {}", repository.display());
8546 }
8547 let unsafe_index_flags = unsafe_index_flags(repository)?;
8548 let tracked = tracked_entries(repository)?;
8549 let gitlinks = gitlinks(repository)?;
8550 if repositories
8551 .insert(
8552 prefix.to_path_buf(),
8553 RepositoryState {
8554 head,
8555 unsafe_index_flags,
8556 tracked,
8557 gitlinks: gitlinks
8558 .iter()
8559 .map(|link| (link.path.clone(), link.oid.clone()))
8560 .collect(),
8561 },
8562 )
8563 .is_some()
8564 {
8565 bail!("Git state contains duplicate repository path {:?}", prefix);
8566 }
8567
8568 for link in gitlinks {
8569 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
8570 continue;
8571 };
8572 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
8573 }
8574 Ok(())
8575}
8576
8577fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
8578 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
8579 if !listed.is_empty() && !listed.ends_with(&[0]) {
8580 bail!(
8581 "git returned an unterminated index-flag listing for {}",
8582 cwd.display()
8583 );
8584 }
8585 let mut unsafe_records = Vec::new();
8586 for record in listed
8587 .split(|byte| *byte == 0)
8588 .filter(|record| !record.is_empty())
8589 {
8590 if record.len() < 3 || record[1] != b' ' {
8591 bail!(
8592 "git returned a malformed index-flag record for {}",
8593 cwd.display()
8594 );
8595 }
8596 if record[0] != b'H' {
8597 unsafe_records.extend_from_slice(record);
8598 unsafe_records.push(0);
8599 }
8600 }
8601 Ok(unsafe_records)
8602}
8603
8604pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
8605 safe_git_state(cwd).map(|_| ())
8606}
8607
8608pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
8609 let state = git_state(cwd)?;
8610 if let Some((path, _repository)) = state
8611 .repositories
8612 .iter()
8613 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
8614 {
8615 let label = if path.as_os_str().is_empty() {
8616 cwd.to_path_buf()
8617 } else {
8618 cwd.join(path)
8619 };
8620 bail!(
8621 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
8622 SPAR cannot prove the working files are unchanged, so it was kept.",
8623 label.display()
8624 );
8625 }
8626 Ok(state)
8627}
8628
8629fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
8630 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
8631}
8632
8633fn repository_has_recoverable_work_with(
8634 cwd: &Path,
8635 include_ignored: bool,
8636 disposable: Disposable,
8637) -> Result<bool> {
8638 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
8639 return Ok(true);
8640 }
8641 let mut visited = BTreeSet::new();
8642 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
8643}
8644
8645fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
8646 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
8647 let git_dir = PathBuf::from(git_dir.trim());
8648 let git_dir = if git_dir.is_absolute() {
8649 git_dir
8650 } else {
8651 cwd.join(git_dir)
8652 };
8653 let git_dir = std::fs::canonicalize(&git_dir)
8654 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8655 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8656 Ok(_) => return Ok(true),
8657 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8658 Err(error) => {
8659 return Err(spar_err!(
8660 "could not inspect per-worktree configuration in {}: {error}",
8661 git_dir.display()
8662 ))
8663 }
8664 }
8665
8666 let orig_head = git_dir.join("ORIG_HEAD");
8667 match std::fs::symlink_metadata(&orig_head) {
8668 Ok(metadata) if metadata.is_file() => {
8669 let oid = std::fs::read_to_string(&orig_head)
8670 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8671 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8672 return Ok(true);
8673 };
8674 if !commit_has_shared_ref(cwd, &commit)? {
8675 return Ok(true);
8676 }
8677 }
8678 Ok(_) => return Ok(true),
8679 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8680 Err(error) => {
8681 return Err(spar_err!(
8682 "could not inspect {}: {error}",
8683 orig_head.display()
8684 ))
8685 }
8686 }
8687
8688 let edit_message = git_dir.join("COMMIT_EDITMSG");
8689 match std::fs::symlink_metadata(&edit_message) {
8690 Ok(metadata) if metadata.is_file() => {
8691 let draft = std::fs::read(&edit_message)
8692 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8693 if draft != head_commit_message(cwd)? {
8694 return Ok(true);
8695 }
8696 }
8697 Ok(_) => return Ok(true),
8698 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8699 Err(error) => {
8700 return Err(spar_err!(
8701 "could not inspect {}: {error}",
8702 edit_message.display()
8703 ))
8704 }
8705 }
8706
8707 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8708 return Ok(true);
8709 }
8710
8711 let local_refs = run_git_bytes(
8712 cwd,
8713 &[
8714 "for-each-ref",
8715 "--format=%(refname)",
8716 "refs/worktree",
8717 "refs/bisect",
8718 "refs/rewritten",
8719 ],
8720 )?;
8721 if !local_refs.is_empty() {
8722 return Ok(true);
8723 }
8724
8725 for entry in std::fs::read_dir(&git_dir)
8729 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8730 {
8731 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8732 let known = matches!(
8733 entry.file_name().to_str(),
8734 Some(
8735 "HEAD"
8736 | "ORIG_HEAD"
8737 | "COMMIT_EDITMSG"
8738 | "FETCH_HEAD"
8739 | "commondir"
8740 | "gitdir"
8741 | "index"
8742 | "logs"
8743 | "refs"
8744 )
8745 );
8746 if !known {
8747 return Ok(true);
8748 }
8749 }
8750
8751 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8752 if !commit_has_shared_ref(cwd, head.trim())? {
8753 return Ok(true);
8754 }
8755 Ok(false)
8756}
8757
8758fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8759 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8760 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8761 bail!(
8762 "git returned a commit without a message separator in {}",
8763 cwd.display()
8764 );
8765 };
8766 Ok(commit[split + 2..].to_vec())
8767}
8768
8769fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8770 let metadata = match std::fs::symlink_metadata(logs) {
8771 Ok(metadata) => metadata,
8772 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8773 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8774 };
8775 if !metadata.is_dir() {
8776 return Ok(true);
8777 }
8778 let mut files = Vec::new();
8779 let mut directories = vec![logs.to_path_buf()];
8780 while let Some(directory) = directories.pop() {
8781 for entry in std::fs::read_dir(&directory)
8782 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8783 {
8784 let entry =
8785 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8786 let path = entry.path();
8787 let kind = entry
8788 .file_type()
8789 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8790 if kind.is_dir() {
8791 directories.push(path);
8792 } else if kind.is_file() {
8793 files.push(path);
8794 } else {
8795 return Ok(true);
8796 }
8797 }
8798 }
8799
8800 let mut commits = BTreeSet::new();
8801 for path in files {
8802 if !collect_reflog_commits(cwd, &path, &mut commits)? {
8803 return Ok(true);
8804 }
8805 }
8806 for commit in commits {
8807 if !commit_has_shared_ref(cwd, &commit)? {
8808 return Ok(true);
8809 }
8810 }
8811 Ok(false)
8812}
8813
8814fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8818 let common = common_git_dir(cwd)?;
8819 let reflog = common.join("logs").join(refname);
8820 let metadata = match std::fs::symlink_metadata(&reflog) {
8821 Ok(metadata) => metadata,
8822 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8823 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8824 };
8825 if !metadata.is_file() {
8826 return Ok(false);
8827 }
8828 let mut commits = BTreeSet::new();
8829 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8830 return Ok(false);
8831 }
8832 for commit in commits {
8833 if is_ancestor(cwd, &commit, durable_tip)?
8834 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8835 {
8836 continue;
8837 }
8838 return Ok(false);
8839 }
8840 Ok(true)
8841}
8842
8843fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8844 let file = std::fs::File::open(path)
8845 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8846 for line in std::io::BufReader::new(file).lines() {
8847 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8848 let mut fields = line.splitn(3, ' ');
8849 let Some(old) = fields.next() else {
8850 return Ok(false);
8851 };
8852 let Some(new) = fields.next() else {
8853 return Ok(false);
8854 };
8855 if fields.next().is_none() {
8856 return Ok(false);
8857 }
8858 for oid in [old, new] {
8859 if oid.bytes().all(|byte| byte == b'0') {
8860 continue;
8861 }
8862 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8863 return Ok(false);
8864 };
8865 commits.insert(commit);
8866 }
8867 }
8868 Ok(true)
8869}
8870
8871fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8872 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8873 let path = PathBuf::from(raw.trim());
8874 let path = if path.is_absolute() {
8875 path
8876 } else {
8877 cwd.join(path)
8878 };
8879 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8880}
8881
8882fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8883 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8884 let output = proc::exec(
8885 &argv,
8886 &ExecOpts::new()
8887 .cwd(cwd)
8888 .timeout_secs(30)
8889 .check(false)
8890 .stop_descendants(true),
8891 )?;
8892 match output.code {
8893 0 => Ok(true),
8894 1 => Ok(false),
8895 _ => bail!("{}", proc::failure_message(&argv, &output)),
8896 }
8897}
8898
8899fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8900 let commit = format!("{oid}^{{commit}}");
8901 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8902 let output = proc::exec(
8903 &argv,
8904 &ExecOpts::new()
8905 .cwd(cwd)
8906 .timeout_secs(30)
8907 .check(false)
8908 .stop_descendants(true),
8909 )?;
8910 if output.code != 0 {
8911 return Ok(None);
8912 }
8913 let oid = output.stdout.trim();
8914 if oid.is_empty() {
8915 return Ok(None);
8916 }
8917 Ok(Some(oid.to_string()))
8918}
8919
8920fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8921 commit_has_shared_ref_except(cwd, oid, None)
8922}
8923
8924fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8925 let contains = format!("--contains={oid}");
8926 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8927 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8928 !record.is_empty()
8929 && !record.starts_with(b"refs/worktree/")
8930 && !record.starts_with(b"refs/bisect/")
8931 && !record.starts_with(b"refs/rewritten/")
8932 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8933 }))
8934}
8935
8936fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8951 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8952 for raw in &ordinary {
8953 let (path, _) = untracked_record(raw, "untracked")?;
8954 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8955 continue;
8956 }
8957 return Ok(true);
8958 }
8959 let listed = untracked_listing(
8960 cwd,
8961 &[
8962 "ls-files",
8963 "--others",
8964 "--ignored",
8965 "--exclude-standard",
8966 "-z",
8967 ],
8968 )?;
8969 for raw in listed {
8970 let (path, nested) = untracked_record(&raw, "ignored")?;
8971 if nested {
8975 return Ok(true);
8976 }
8977 if disposable == Disposable::BuildOutput
8978 && !is_generated_artifact(&path)
8979 && !is_desktop_metadata(&path)
8980 {
8981 return Ok(true);
8982 }
8983 }
8984 Ok(false)
8985}
8986
8987fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8988 let listed = run_git_bytes(cwd, args)?;
8989 if !listed.is_empty() && !listed.ends_with(&[0]) {
8990 bail!(
8991 "git returned an unterminated untracked-file list for {}",
8992 cwd.display()
8993 );
8994 }
8995 Ok(listed
8996 .split(|byte| *byte == 0)
8997 .filter(|raw| !raw.is_empty())
8998 .map(|raw| raw.to_vec())
8999 .collect())
9000}
9001
9002fn repository_has_recoverable_work_inner(
9003 cwd: &Path,
9004 include_ignored: bool,
9005 disposable: Disposable,
9006 visited: &mut BTreeSet<PathBuf>,
9007) -> Result<bool> {
9008 let canonical = std::fs::canonicalize(cwd)
9009 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
9010 if !visited.insert(canonical.clone()) {
9011 bail!("submodule recursion revisited {}", canonical.display());
9012 }
9013 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
9014 return Ok(true);
9015 }
9016 if !unsafe_index_flags(cwd)?.is_empty() {
9017 return Ok(true);
9018 }
9019 if attributes_may_be_modified(cwd)? {
9020 return Ok(true);
9021 }
9022 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
9023 return Ok(true);
9024 }
9025 if include_ignored {
9026 let index = index_entries(cwd)?
9027 .into_iter()
9028 .map(|entry| (entry.path, (entry.mode, entry.oid)))
9029 .collect::<BTreeMap<_, _>>();
9030 let head = tree_entries(cwd, "HEAD")?
9031 .into_iter()
9032 .map(|entry| (entry.path, (entry.mode, entry.oid)))
9033 .collect::<BTreeMap<_, _>>();
9034 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
9035 return Ok(true);
9036 }
9037 let tracked = tracked_entries(cwd)?;
9038 let effective = check_attributes(cwd, tracked.keys().cloned())?;
9039 let config = CheckoutConfig::read(cwd)?;
9040 for (path, entry) in tracked {
9041 let Some(worktree) = entry.worktree else {
9042 return Ok(true);
9043 };
9044 let attributes = effective.get(&path).ok_or_else(|| {
9045 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
9046 })?;
9047 if path_has_ambiguous_transform(&config, attributes)? {
9048 return Ok(true);
9049 }
9050 let symlink_file = entry.index_mode == "120000"
9051 && worktree.mode == "100644"
9052 && worktree.raw_oid == entry.index_oid
9053 && config.symlinks == Some(false);
9054 if worktree.mode != entry.index_mode && !symlink_file {
9055 return Ok(true);
9056 }
9057 if entry.index_mode == "120000" {
9058 if worktree.raw_oid != entry.index_oid {
9059 return Ok(true);
9060 }
9061 continue;
9062 }
9063 #[cfg(unix)]
9064 {
9065 let expected = if entry.index_mode == "100755" {
9066 0o755
9067 } else {
9068 0o644
9069 };
9070 if worktree.permissions != expected {
9071 return Ok(true);
9072 }
9073 }
9074 if allows_expected_crlf(&config, attributes)? {
9075 let (normalized, every_lf_was_crlf) =
9076 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
9077 if !every_lf_was_crlf || normalized != entry.index_oid {
9078 return Ok(true);
9079 }
9080 } else if worktree.raw_oid != entry.index_oid {
9081 return Ok(true);
9082 }
9083 }
9084 } else {
9085 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
9086 if !run_git_bytes(cwd, &args)?.is_empty() {
9087 return Ok(true);
9088 }
9089 }
9090 for link in gitlinks(cwd)? {
9091 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
9092 continue;
9093 };
9094 if include_ignored {
9099 return Ok(true);
9100 }
9101 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
9102 if head.trim() != link.oid {
9103 return Ok(true);
9104 }
9105 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
9106 {
9107 return Ok(true);
9108 }
9109 }
9110 Ok(false)
9111}
9112
9113pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
9114 repository_has_recoverable_work(cwd, false)
9115}
9116
9117fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
9118 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
9119 Ok(!run_git_bytes(cwd, &args)?.is_empty())
9120}
9121
9122#[cfg(unix)]
9123fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
9124 use std::os::unix::ffi::OsStringExt;
9125 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
9126}
9127
9128#[cfg(not(unix))]
9129fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
9130 String::from_utf8(raw.to_vec())
9131 .map(PathBuf::from)
9132 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
9133}
9134
9135fn vanished_untracked_file() -> UntrackedFile {
9151 UntrackedFile {
9152 kind: 0,
9153 len: 0,
9154 modified: None,
9155 created: None,
9156 readonly: false,
9157 symlink_target: None,
9158 #[cfg(unix)]
9159 device: 0,
9160 #[cfg(unix)]
9161 inode: 0,
9162 #[cfg(unix)]
9163 mode: 0,
9164 #[cfg(unix)]
9165 change_seconds: 0,
9166 #[cfg(unix)]
9167 change_nanoseconds: 0,
9168 }
9169}
9170
9171fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
9172 let metadata = match std::fs::symlink_metadata(path) {
9173 Ok(metadata) => metadata,
9174 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
9175 Err(e) => {
9176 return Err(spar_err!(
9177 "could not inspect the nested repository at {}: {e}",
9178 path.display()
9179 ))
9180 }
9181 };
9182 if !metadata.is_dir() {
9183 bail!(
9184 "git reported {} as a nested repository, but it is not a directory",
9185 path.display()
9186 );
9187 }
9188 if std::fs::symlink_metadata(path.join(".git")).is_err() {
9189 bail!(
9190 "git reported {} as a nested repository, but it has no Git entry",
9191 path.display()
9192 );
9193 }
9194 #[cfg(unix)]
9195 {
9196 use std::os::unix::fs::MetadataExt;
9197 Ok(UntrackedFile {
9198 kind: 3,
9199 len: 0,
9200 modified: None,
9201 created: metadata.created().ok(),
9202 readonly: metadata.permissions().readonly(),
9203 symlink_target: None,
9204 device: metadata.dev(),
9205 inode: metadata.ino(),
9206 mode: metadata.mode(),
9207 change_seconds: 0,
9208 change_nanoseconds: 0,
9209 })
9210 }
9211 #[cfg(not(unix))]
9212 {
9213 Ok(UntrackedFile {
9214 kind: 3,
9215 len: 0,
9216 modified: None,
9217 created: metadata.created().ok(),
9218 readonly: metadata.permissions().readonly(),
9219 symlink_target: None,
9220 })
9221 }
9222}
9223
9224fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
9225 let metadata = match std::fs::symlink_metadata(path) {
9226 Ok(metadata) => metadata,
9227 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
9228 Err(e) => {
9229 return Err(spar_err!(
9230 "could not inspect untracked file {}: {e}",
9231 path.display()
9232 ))
9233 }
9234 };
9235 let kind = if metadata.file_type().is_symlink() {
9236 2
9237 } else if metadata.is_file() {
9238 1
9239 } else {
9240 bail!(
9241 "untracked path {} is not a regular file or symlink",
9242 path.display()
9243 );
9244 };
9245 let symlink_target = if kind == 2 {
9246 match std::fs::read_link(path) {
9247 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
9248 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
9249 return Ok(vanished_untracked_file())
9250 }
9251 Err(e) => {
9252 return Err(spar_err!(
9253 "could not read untracked symlink {}: {e}",
9254 path.display()
9255 ))
9256 }
9257 }
9258 } else {
9259 None
9260 };
9261 #[cfg(unix)]
9262 {
9263 use std::os::unix::fs::MetadataExt;
9264 Ok(UntrackedFile {
9265 kind,
9266 len: metadata.len(),
9267 modified: metadata.modified().ok(),
9268 created: metadata.created().ok(),
9269 readonly: metadata.permissions().readonly(),
9270 symlink_target,
9271 device: metadata.dev(),
9272 inode: metadata.ino(),
9273 mode: metadata.mode(),
9274 change_seconds: metadata.ctime(),
9275 change_nanoseconds: metadata.ctime_nsec(),
9276 })
9277 }
9278 #[cfg(not(unix))]
9279 {
9280 Ok(UntrackedFile {
9281 kind,
9282 len: metadata.len(),
9283 modified: metadata.modified().ok(),
9284 created: metadata.created().ok(),
9285 readonly: metadata.permissions().readonly(),
9286 symlink_target,
9287 })
9288 }
9289}
9290
9291#[cfg(unix)]
9292fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
9293 use std::os::unix::ffi::OsStrExt;
9294 Ok(value.as_bytes().to_vec())
9295}
9296
9297#[cfg(not(unix))]
9298fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
9299 value
9300 .to_str()
9301 .map(|value| value.as_bytes().to_vec())
9302 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
9303}
9304
9305#[cfg(unix)]
9306fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
9307 use std::os::unix::fs::MetadataExt;
9308 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
9309}
9310
9311#[cfg(not(unix))]
9312fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
9313 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
9314}
9315
9316#[derive(Debug, Clone, serde::Serialize, Deserialize)]
9317pub struct BranchRecord {
9318 pub kind: String,
9319 pub number: i64,
9320}
9321
9322pub fn review_ref(number: i64) -> String {
9325 format!("refs/spar/pr-{number}")
9326}
9327
9328pub fn is_finished(state: &str) -> bool {
9329 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
9330}
9331
9332pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
9339 if let Some(parent) = path.parent() {
9340 std::fs::create_dir_all(parent)
9341 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
9342 }
9343 let tmp = path.with_extension(format!(
9346 "{}.tmp",
9347 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
9348 ));
9349 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
9350 std::fs::rename(&tmp, path)
9351 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
9352 Ok(())
9353}
9354
9355pub fn has_heading(text: &str, title: &str) -> bool {
9362 let wanted = title.trim().to_lowercase();
9363 if wanted.is_empty() {
9364 return false;
9365 }
9366 text.lines()
9367 .filter_map(|line| line.trim().strip_prefix("## "))
9368 .any(|heading| heading.trim().to_lowercase() == wanted)
9369}
9370
9371pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
9374 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
9375}
9376
9377#[derive(Deserialize)]
9379#[serde(rename_all = "camelCase")]
9380pub struct LinkedPrRow {
9381 number: i64,
9382 #[serde(default)]
9383 url: String,
9384 #[serde(default)]
9385 title: String,
9386 #[serde(default)]
9387 closing_issues_references: Vec<IssueRef>,
9388}
9389
9390pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
9397 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
9398}
9399
9400pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
9402 rows.iter()
9403 .find(|row| {
9404 row.closing_issues_references
9405 .iter()
9406 .any(|linked| linked.number == issue)
9407 })
9408 .map(|row| PrRef {
9409 number: row.number,
9410 url: row.url.clone(),
9411 title: row.title.clone(),
9412 })
9413}
9414
9415pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
9417 pick_linked_pr(&linked_pr_rows(json)?, issue)
9418}
9419
9420pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
9427 if text.trim().is_empty() {
9428 return Err(spar_err!("GitHub returned no comment data"));
9429 }
9430 let mut out = Vec::new();
9431 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9432 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
9433 Value::Array(items) => out.extend(items),
9434 _ => return Err(spar_err!("unexpected non-array comment page")),
9435 }
9436 }
9437 Ok(out)
9438}
9439
9440pub fn parse_comment_pages(text: &str) -> Vec<Value> {
9441 let mut out = Vec::new();
9442 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9443 match value {
9444 Ok(Value::Array(items)) => out.extend(items),
9445 Ok(other) => out.push(other),
9446 Err(_) => break,
9447 }
9448 }
9449 out
9450}
9451
9452pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
9460 body.trim_start().starts_with(STATE_MARKER)
9461 && !viewer.trim().is_empty()
9462 && author.trim().eq_ignore_ascii_case(viewer.trim())
9463 && parse_state_comment(body).is_some()
9464}
9465
9466pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
9469 let marker = body.find(STATE_MARKER)?;
9470 let start = body[marker..].find('{')? + marker;
9471 let end = body.rfind('}')?;
9472 if end <= start {
9473 return None;
9474 }
9475 match serde_json::from_str(&body[start..=end]) {
9476 Ok(state) => Some(state),
9477 Err(_) => {
9478 logdim!("found a spar state comment but could not parse it");
9479 None
9480 }
9481 }
9482}
9483
9484fn choose_state_for_head(
9485 candidates: Vec<PersistedState>,
9486 actual_head: &str,
9487) -> Option<PersistedState> {
9488 let matching: Vec<PersistedState> = candidates
9489 .iter()
9490 .filter(|state| state.pr_head == actual_head)
9491 .cloned()
9492 .collect();
9493 if !matching.is_empty() {
9494 return newest_state(matching);
9495 }
9496 newest_state(candidates)
9497}
9498
9499fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
9500 candidates.into_iter().reduce(|best, candidate| {
9501 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
9502 candidate
9503 } else {
9504 best
9509 }
9510 })
9511}
9512
9513pub fn self_binary() -> Result<PathBuf> {
9519 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
9520 let path = PathBuf::from(path);
9521 if proc::is_executable(&path) {
9522 return Ok(path);
9523 }
9524 bail!(
9525 "SPAR_SELF_BIN is set to {}, which is not executable",
9526 path.display()
9527 );
9528 }
9529 std::env::current_exe()
9530 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
9531}
9532
9533fn bool_env(value: bool) -> &'static str {
9534 if value {
9535 "1"
9536 } else {
9537 "0"
9538 }
9539}
9540
9541pub fn sh_quote(text: &str) -> String {
9544 format!("'{}'", text.replace('\'', r"'\''"))
9545}
9546
9547pub fn style_from_env() -> Style {
9550 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
9551 Style {
9552 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
9553 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
9554 ..Style::permissive()
9555 }
9556}
9557
9558#[cfg(test)]
9559mod tests {
9560 use super::*;
9561 use crate::config::StateStore;
9562 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
9563 use std::process::Command;
9564
9565 fn repo_for_titles() -> Repo {
9566 Repo {
9567 root: PathBuf::from("/nonexistent"),
9568 style: Style::default(),
9569 branch_prefix: String::new(),
9570 state_store: StateStore::Local,
9571 followups: crate::config::Followups::Issues,
9572 drafts: Drafts::Never,
9573 fork_when_denied: true,
9574 setup: CheckCommand::default(),
9575 isolate_reads: true,
9576 keep_worktrees: false,
9577 fork: OnceLock::new(),
9578 viewer: OnceLock::new(),
9579 checkpoints: Mutex::new(BTreeMap::new()),
9580 writes: WriteStats::default(),
9581 }
9582 }
9583
9584 #[test]
9587 fn only_a_read_checkout_name_names_its_owner() {
9588 assert_eq!(Some(4242), read_checkout_owner("read-4242-0"));
9589 assert_eq!(None, read_checkout_owner("read-4242"));
9590 assert_eq!(None, read_checkout_owner("read-me-0"));
9591 assert_eq!(None, read_checkout_owner("read-4242-x"));
9592 assert_eq!(None, read_checkout_owner("review-4242"));
9593 }
9594
9595 #[test]
9596 fn write_results_accumulate_for_the_run() {
9597 let repo = repo_for_titles();
9598
9599 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
9600 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
9601
9602 assert_eq!(
9603 WriteSummary {
9604 attempted: 2,
9605 failed: 1,
9606 },
9607 repo.write_summary()
9608 );
9609 }
9610
9611 #[test]
9612 fn only_failed_write_preflights_join_the_summary() {
9613 let repo = repo_for_titles();
9614
9615 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
9616 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
9617
9618 assert_eq!(
9619 WriteSummary {
9620 attempted: 1,
9621 failed: 1,
9622 },
9623 repo.write_summary()
9624 );
9625 }
9626
9627 #[test]
9628 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
9629 let repo = repo_for_titles();
9630
9631 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
9632 assert_eq!(
9633 WriteSummary {
9634 attempted: 1,
9635 failed: 1,
9636 },
9637 repo.write_summary()
9638 );
9639 }
9640
9641 #[test]
9642 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
9643 let mut repo = repo_for_titles();
9644 repo.followups = Followups::Local;
9645
9646 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
9647 assert_eq!(WriteSummary::default(), repo.write_summary());
9648 }
9649
9650 #[test]
9651 fn a_failed_remote_state_read_stops_before_state_mutation() {
9652 let root = std::env::temp_dir().join(format!(
9653 "spar-state-preflight-{}-{}",
9654 std::process::id(),
9655 std::time::SystemTime::now()
9656 .duration_since(std::time::UNIX_EPOCH)
9657 .unwrap()
9658 .as_nanos()
9659 ));
9660 std::fs::create_dir_all(&root).unwrap();
9661 let _fixture = ReviewFixture { root: root.clone() };
9662 let mut repo = repo_for_titles();
9663 repo.root = root;
9664 repo.state_store = StateStore::Both;
9665 let state = PersistedState {
9666 version: 1,
9667 checkpoint: 4,
9668 round: 2,
9669 next_actor: "a".into(),
9670 status: Status::Pending,
9671 pr_head: "abc123".into(),
9672 ledger: Ledger::new(),
9673 filed: Vec::new(),
9674 open_findings: Vec::new(),
9675 disputes: Vec::new(),
9676 noted: Vec::new(),
9677 };
9678
9679 let error = repo
9680 .write_state_after_remote_read(
9681 7,
9682 &state,
9683 Err(crate::error::SparError::new("state comments unavailable")),
9684 )
9685 .unwrap_err();
9686
9687 assert!(error.to_string().contains("state comments unavailable"));
9688 assert!(!repo.state_path(7).exists());
9689 assert_eq!(0, repo.remembered_checkpoint(7));
9690 assert_eq!(
9691 WriteSummary {
9692 attempted: 1,
9693 failed: 1,
9694 },
9695 repo.write_summary()
9696 );
9697 }
9698
9699 #[test]
9700 fn only_known_build_and_cache_directories_are_generated_artifacts() {
9701 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9702 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9703 assert!(is_generated_artifact(Path::new(
9704 "package/node_modules/dependency/file.js"
9705 )));
9706 assert!(!is_generated_artifact(Path::new(
9707 "distribution/required-package.js"
9708 )));
9709 assert!(!is_generated_artifact(Path::new(
9710 "generated/required-fixture.txt"
9711 )));
9712 assert!(!is_generated_artifact(Path::new("local.env")));
9713 }
9714
9715 #[test]
9716 fn only_finder_state_files_are_desktop_metadata() {
9717 assert!(is_desktop_metadata(Path::new(".DS_Store")));
9718 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9719 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9720 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9721 }
9722
9723 struct ReviewFixture {
9724 root: PathBuf,
9725 }
9726
9727 impl Drop for ReviewFixture {
9728 fn drop(&mut self) {
9729 let _ = std::fs::remove_dir_all(&self.root);
9730 }
9731 }
9732
9733 fn test_git(cwd: &Path, args: &[&str]) -> String {
9734 let output = Command::new("git")
9735 .args(args)
9736 .current_dir(cwd)
9737 .output()
9738 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9739 assert!(
9740 output.status.success(),
9741 "git {args:?} failed: {}",
9742 String::from_utf8_lossy(&output.stderr)
9743 );
9744 String::from_utf8_lossy(&output.stdout).into_owned()
9745 }
9746
9747 #[test]
9751 fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9752 let root =
9753 std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9754 let _cleanup = ReviewFixture { root: root.clone() };
9755 let origin = root.join("origin.git");
9756 let work = root.join("work");
9757 let marker = root.join("textconv-ran");
9758 std::fs::create_dir_all(&origin).unwrap();
9759 std::fs::create_dir_all(&work).unwrap();
9760 test_git(&origin, &["init", "--bare", "-b", "main"]);
9761 test_git(&work, &["init", "-b", "main"]);
9762 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9763 test_git(&work, &["config", "user.name", "spar test"]);
9764 test_git(&work, &["config", "commit.gpgsign", "false"]);
9765 test_git(&work, &["config", "diff.noprefix", "true"]);
9766 test_git(&work, &["config", "diff.context", "0"]);
9767 test_git(&work, &["config", "diff.interHunkContext", "10"]);
9768 test_git(&work, &["config", "diff.algorithm", "patience"]);
9769 test_git(&work, &["config", "color.diff", "always"]);
9770 test_git(
9771 &work,
9772 &[
9773 "config",
9774 "diff.conv.textconv",
9775 &format!("touch {}; cat", marker.display()),
9776 ],
9777 );
9778 std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9779 let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9780 std::fs::write(work.join("src.txt"), &lines).unwrap();
9781 test_git(&work, &["add", "."]);
9782 test_git(&work, &["commit", "-m", "seed"]);
9783 test_git(
9784 &work,
9785 &["remote", "add", "origin", origin.to_str().unwrap()],
9786 );
9787 test_git(&work, &["push", "-u", "origin", "main"]);
9788 test_git(&work, &["checkout", "-b", "feature"]);
9789 std::fs::write(
9790 work.join("src.txt"),
9791 lines
9792 .replace("line 5\n", "changed 5\n")
9793 .replace("line 15\n", "changed 15\n"),
9794 )
9795 .unwrap();
9796 std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9797 std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9798 test_git(&work, &["add", "."]);
9799 test_git(&work, &["commit", "-m", "change"]);
9800 let cfg = crate::config::parse(
9801 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9802 )
9803 .unwrap();
9804 let repo = Repo::open(&work, &cfg).unwrap();
9805 assert_eq!(
9806 Hunks::from([
9807 ("new.txt".to_string(), vec![(1, 2)]),
9808 ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9809 ("with space.txt".to_string(), vec![(1, 1)]),
9810 ]),
9811 repo.diff_hunks(&work, "main")
9812 );
9813 assert!(!marker.exists(), "the text converter ran");
9814 test_git(&work, &["diff", "origin/main...HEAD"]);
9816 assert!(marker.exists(), "the fixture's converter never runs at all");
9817 }
9818
9819 fn review_fixture(
9820 tag: &str,
9821 number: i64,
9822 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9823 use std::sync::atomic::{AtomicU32, Ordering};
9824 static NEXT: AtomicU32 = AtomicU32::new(0);
9825 let id = NEXT.fetch_add(1, Ordering::Relaxed);
9826 let root =
9827 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9828 let origin = root.join("origin.git");
9829 let work = root.join("work");
9830 std::fs::create_dir_all(&origin).unwrap();
9831 std::fs::create_dir_all(&work).unwrap();
9832 test_git(&origin, &["init", "--bare", "-b", "main"]);
9833 test_git(&work, &["init", "-b", "main"]);
9834 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9835 test_git(&work, &["config", "user.name", "spar test"]);
9836 test_git(&work, &["config", "commit.gpgsign", "false"]);
9837 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9838 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9839 std::fs::write(work.join("README.md"), "seed\n").unwrap();
9840 std::fs::write(work.join("data.txt"), "old\n").unwrap();
9841 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9842 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9843 test_git(&work, &["add", "."]);
9844 test_git(&work, &["commit", "-m", "seed"]);
9845 test_git(
9846 &work,
9847 &["remote", "add", "origin", origin.to_str().unwrap()],
9848 );
9849 test_git(&work, &["push", "-u", "origin", "main"]);
9850 test_git(
9851 &work,
9852 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9853 );
9854 let cfg = crate::config::parse(
9855 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9856 )
9857 .unwrap();
9858 let repo = Repo::open(&work, &cfg).unwrap();
9859 let path = repo.worktree_for_pr_head(number).unwrap();
9860 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9861 (ReviewFixture { root }, repo, path, checkpoint)
9862 }
9863
9864 #[test]
9865 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9866 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9867
9868 repo.release_review_worktree_checked(901, &checkpoint)
9869 .unwrap();
9870
9871 assert!(!path.exists());
9872 }
9873
9874 #[test]
9875 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9876 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9877 let work = fixture.root.join("work");
9878 let seed = test_git(&work, &["rev-parse", "HEAD"]);
9879 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9880 let publish = |message: &str| {
9883 let oid = test_git(
9884 &work,
9885 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9886 );
9887 let oid = oid.trim().to_string();
9888 test_git(
9889 &work,
9890 &[
9891 "push",
9892 "--force",
9893 "origin",
9894 &format!("{oid}:refs/pull/923/head"),
9895 ],
9896 );
9897 oid
9898 };
9899 let reviewed = publish("reviewed head");
9900 repo.worktree_for_pr_head(923).unwrap();
9901 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9902
9903 let rewritten = publish("rewritten head");
9904 repo.worktree_for_pr_head(923).unwrap();
9905
9906 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9907 }
9908
9909 #[test]
9910 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9911 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9912 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9913 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9914 test_git(&path, &["add", "recovery.txt"]);
9915 test_git(&path, &["commit", "-m", "recovery commit"]);
9916 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9917 test_git(&path, &["reset", "--hard", "main"]);
9918
9919 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9920 test_git(
9921 &path,
9922 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9923 );
9924 }
9925
9926 #[test]
9927 fn a_review_ref_reflog_only_commit_prevents_deletion() {
9928 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9929 let local_ref = review_ref(921);
9930 let original = test_git(&path, &["rev-parse", &local_ref]);
9931 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9932 let recovery = test_git(
9933 &path,
9934 &[
9935 "commit-tree",
9936 tree.trim(),
9937 "-p",
9938 original.trim(),
9939 "-m",
9940 "review ref recovery",
9941 ],
9942 );
9943 test_git(
9944 &path,
9945 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9946 );
9947 test_git(
9948 &path,
9949 &["update-ref", &local_ref, original.trim(), recovery.trim()],
9950 );
9951
9952 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9953 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9954 test_git(
9955 &path,
9956 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9957 );
9958 }
9959
9960 #[test]
9961 fn an_unpublished_commit_message_draft_is_recoverable() {
9962 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9963 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9964 let git_dir = if raw.is_absolute() {
9965 raw
9966 } else {
9967 path.join(raw)
9968 };
9969 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9970
9971 assert!(repository_has_recoverable_work(&path, true).unwrap());
9972 assert_eq!(
9973 "unique recovery draft\n",
9974 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9975 );
9976 }
9977
9978 #[test]
9979 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9980 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9981 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9982
9983 let error = repo
9984 .release_review_worktree_checked(902, &checkpoint)
9985 .unwrap_err();
9986
9987 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9988 assert!(error.to_string().contains("kept for recovery"), "{error}");
9989 assert_eq!(
9990 "recover me\n",
9991 std::fs::read_to_string(path.join("README.md")).unwrap()
9992 );
9993 repo.release_review_worktree(902);
9994 }
9995
9996 #[test]
9997 fn a_review_commit_is_retained_after_a_checked_read() {
9998 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9999 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
10000 test_git(&path, &["add", "review-note.txt"]);
10001 test_git(&path, &["commit", "-m", "local review recovery"]);
10002 let head = test_git(&path, &["rev-parse", "HEAD"]);
10003
10004 let error = repo
10005 .release_review_worktree_checked(903, &checkpoint)
10006 .unwrap_err();
10007
10008 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10009 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
10010 assert_eq!(
10011 "recover me\n",
10012 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
10013 );
10014 repo.release_review_worktree(903);
10015 }
10016
10017 #[test]
10021 fn an_ignored_review_file_is_retained_after_a_checked_read() {
10022 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
10023 std::fs::create_dir_all(path.join("generated")).unwrap();
10024 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
10025
10026 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10027 .expect("its own output is not a change");
10028 let error = repo
10029 .release_review_worktree_checked(904, &checkpoint)
10030 .unwrap_err();
10031
10032 assert!(error.to_string().contains("recoverable"), "{error}");
10033 assert_eq!(
10034 "recover me\n",
10035 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
10036 );
10037 repo.release_review_worktree(904);
10038 }
10039
10040 #[test]
10041 fn a_preexisting_ignored_review_file_change_is_retained() {
10047 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
10048 std::fs::create_dir_all(path.join("generated")).unwrap();
10049 let ignored = path.join("generated/recovery.txt");
10050 std::fs::write(&ignored, "before\n").unwrap();
10051 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10052 std::fs::write(&ignored, "after!\n").unwrap();
10053
10054 let error = repo
10055 .release_review_worktree_checked(905, &checkpoint)
10056 .unwrap_err();
10057
10058 assert!(error.to_string().contains("recoverable"), "{error}");
10059 assert!(path.exists());
10060 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
10061 repo.release_review_worktree(905);
10062 }
10063
10064 #[test]
10065 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
10066 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
10067 std::fs::create_dir_all(path.join("generated")).unwrap();
10068 let ignored = path.join("generated/recovery.txt");
10069 std::fs::write(&ignored, "keep me\n").unwrap();
10070 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10071
10072 let error = repo
10073 .release_review_worktree_checked(906, &checkpoint)
10074 .unwrap_err();
10075
10076 assert!(error.to_string().contains("recoverable"), "{error}");
10077 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
10078 }
10079
10080 #[test]
10081 fn overwriting_a_preexisting_untracked_file_is_detected() {
10082 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
10083 let untracked = path.join("notes.txt");
10084 std::fs::write(&untracked, "before\n").unwrap();
10085 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10086 std::fs::write(&untracked, "after!\n").unwrap();
10087
10088 let error = repo
10089 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10090 .unwrap_err();
10091
10092 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10093 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
10094 }
10095
10096 #[test]
10097 fn an_assume_unchanged_edit_is_detected() {
10098 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
10099 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
10100 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
10101
10102 let error = repo
10103 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10104 .unwrap_err();
10105
10106 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10107 assert_eq!(
10108 "hidden\n",
10109 std::fs::read_to_string(path.join("README.md")).unwrap()
10110 );
10111 }
10112
10113 #[test]
10114 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
10115 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
10116 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
10117 test_git(&path, &["add", "README.md"]);
10118 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10119
10120 let error = repo
10121 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10122 .unwrap_err();
10123
10124 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10125 assert_eq!(
10126 b"seed\r\n",
10127 std::fs::read(path.join("README.md")).unwrap().as_slice()
10128 );
10129 }
10130
10131 #[cfg(unix)]
10132 #[test]
10133 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
10134 use std::os::unix::fs::PermissionsExt;
10135
10136 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
10137 test_git(&path, &["config", "core.filemode", "false"]);
10138 let readme = path.join("README.md");
10139 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
10140 permissions.set_mode(0o755);
10141 std::fs::set_permissions(&readme, permissions).unwrap();
10142 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10143
10144 let error = repo
10145 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10146 .unwrap_err();
10147
10148 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10149 assert_eq!(
10150 0o755,
10151 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
10152 );
10153 }
10154
10155 #[test]
10156 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
10157 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
10158 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
10159 test_git(&path, &["add", ".gitattributes"]);
10160 test_git(&path, &["commit", "-m", "select data filter"]);
10161 let baseline = repo.worktree_baseline(&path).unwrap();
10162 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
10163
10164 assert!(repo
10165 .commit_pending_changes(&path, &baseline, "change data", "change data")
10166 .unwrap());
10167 let error = repo
10168 .refuse_unrepresented_tracked_changes(&path, &baseline)
10169 .unwrap_err();
10170
10171 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10172 assert_eq!(
10173 "secret: recover me\nnew\n",
10174 std::fs::read_to_string(path.join("data.txt")).unwrap()
10175 );
10176 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
10177 }
10178
10179 #[test]
10180 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
10181 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
10182 std::fs::create_dir_all(path.join("target")).unwrap();
10183 let untracked = path.join("target/user.yaml");
10184 std::fs::write(&untracked, "user data\n").unwrap();
10185 let baseline = repo.worktree_baseline(&path).unwrap();
10186 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10187
10188 assert!(repo
10189 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10190 .unwrap());
10191
10192 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
10193 assert_eq!(
10194 "?? target/user.yaml\n",
10195 test_git(&path, &["status", "--short", "--untracked-files=all"])
10196 );
10197 assert!(test_git(
10198 &path,
10199 &[
10200 "ls-tree",
10201 "-r",
10202 "--name-only",
10203 "HEAD",
10204 "--",
10205 "target/user.yaml"
10206 ]
10207 )
10208 .is_empty());
10209 }
10210
10211 #[test]
10212 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
10213 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
10214 std::fs::create_dir_all(path.join("target")).unwrap();
10215 let untracked = path.join("target/user.yaml");
10216 std::fs::write(&untracked, "before\n").unwrap();
10217 let baseline = repo.worktree_baseline(&path).unwrap();
10218 let before = test_git(&path, &["rev-parse", "HEAD"]);
10219 std::fs::write(&untracked, "after\n").unwrap();
10220 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10221
10222 let error = repo
10223 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10224 .unwrap_err();
10225
10226 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10227 assert!(error.to_string().contains("target/user.yaml"), "{error}");
10228 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
10229 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
10230 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
10231 }
10232
10233 #[test]
10234 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
10235 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
10236 let baseline = repo.worktree_baseline(&path).unwrap();
10237 std::fs::create_dir_all(path.join("target")).unwrap();
10238 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
10239
10240 assert!(repo
10241 .commit_pending_changes(&path, &baseline, "add file", "add file")
10242 .unwrap());
10243
10244 assert_eq!(
10245 "new file\n",
10246 test_git(&path, &["show", "HEAD:target/new.txt"])
10247 );
10248 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10249 }
10250
10251 #[test]
10252 fn a_managed_commit_accepts_a_custom_output_rebuild() {
10253 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
10254 exclude_paths(&repo, &["manager/public/"]);
10255 let output = path.join("manager/public/assets");
10256 std::fs::create_dir_all(&output).unwrap();
10257 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
10258 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
10259 let baseline = repo.worktree_baseline(&path).unwrap();
10260 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10261 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
10262 std::fs::remove_file(output.join("old.js")).unwrap();
10263 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
10264 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
10265
10266 assert!(repo
10267 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10268 .unwrap());
10269 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10270 repo.refuse_changed_existing_untracked(&path, &baseline)
10271 .unwrap();
10272 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
10273 .unwrap();
10274
10275 assert_eq!(
10276 "tracked change\n",
10277 test_git(&path, &["show", "HEAD:README.md"])
10278 );
10279 assert_eq!(
10280 "new source\n",
10281 test_git(&path, &["show", "HEAD:new-source.txt"])
10282 );
10283 assert!(test_git(
10284 &path,
10285 &[
10286 "ls-tree",
10287 "-r",
10288 "--name-only",
10289 "HEAD",
10290 "--",
10291 "manager/public"
10292 ]
10293 )
10294 .is_empty());
10295 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10296 assert!(!output.join("old.js").exists());
10297 assert_eq!(
10298 "new styles\n",
10299 std::fs::read_to_string(output.join("index.css")).unwrap()
10300 );
10301 assert!(repository_has_recoverable_work(&path, true).unwrap());
10302 repo.release_review_worktree(912);
10303 assert!(output.join("index-DUym-Rfj.js").exists());
10304 }
10305
10306 #[test]
10307 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
10308 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
10309 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
10310 let baseline = repo.worktree_baseline(&path).unwrap();
10311 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
10312 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
10313
10314 let error = repo
10315 .commit_pending_changes(&path, &baseline, "change", "change")
10316 .unwrap_err();
10317
10318 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10319 assert!(error.to_string().contains("notes.txt"), "{error}");
10320 }
10321
10322 #[test]
10323 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
10324 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
10325 let baseline = repo.worktree_baseline(&path).unwrap();
10326 let nested = path.join("generated/repro");
10327 std::fs::create_dir_all(&nested).unwrap();
10328 test_git(&nested, &["init"]);
10329 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10330
10331 assert!(repo
10332 .commit_pending_changes(&path, &baseline, "change", "change")
10333 .unwrap());
10334 assert!(!repo.has_uncommitted_changes(&path).unwrap());
10335 assert!(repository_has_recoverable_work(&path, true).unwrap());
10336 repo.release_review_worktree(927);
10337 assert!(nested.join(".git").exists());
10338 }
10339
10340 #[test]
10341 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
10342 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
10343 test_git(&path, &["config", "core.autocrlf", "true"]);
10344 std::fs::write(path.join("README.md"), "seed\n").unwrap();
10345 assert_eq!(
10346 test_git(&path, &["hash-object", "README.md"]).trim(),
10347 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10348 );
10349
10350 assert!(repository_has_recoverable_work(&path, true).unwrap());
10351 assert_eq!(
10352 "seed\n",
10353 std::fs::read_to_string(path.join("README.md")).unwrap()
10354 );
10355 }
10356
10357 #[test]
10358 fn autocrlf_input_overrides_a_crlf_core_eol() {
10359 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
10360 test_git(&path, &["config", "core.autocrlf", "input"]);
10361 test_git(&path, &["config", "core.eol", "crlf"]);
10362 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
10363 assert_eq!(
10364 test_git(&path, &["hash-object", "README.md"]).trim(),
10365 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10366 );
10367
10368 assert!(repository_has_recoverable_work(&path, true).unwrap());
10369 assert_eq!(
10370 b"seed\r\n",
10371 std::fs::read(path.join("README.md")).unwrap().as_slice()
10372 );
10373 }
10374
10375 #[cfg(unix)]
10376 #[test]
10377 fn a_non_executable_permission_change_is_recoverable() {
10378 use std::os::unix::fs::PermissionsExt;
10379
10380 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
10381 let readme = path.join("README.md");
10382 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
10383 permissions.set_mode(0o600);
10384 std::fs::set_permissions(&readme, permissions).unwrap();
10385 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10386
10387 let error = repo
10388 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10389 .unwrap_err();
10390
10391 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10392 assert!(repository_has_recoverable_work(&path, true).unwrap());
10393 assert_eq!(
10394 0o600,
10395 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
10396 );
10397 }
10398
10399 #[cfg(unix)]
10400 #[test]
10401 fn a_managed_commit_skips_signing_and_hooks() {
10402 use std::os::unix::fs::PermissionsExt;
10403
10404 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
10405 let common = common_git_dir(&path).unwrap();
10406 let hook = common.join("hooks/pre-commit");
10407 let marker = fixture.root.join("hook-ran");
10408 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
10409 std::fs::write(
10410 &hook,
10411 format!(
10412 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
10413 sh_quote(marker.to_str().unwrap())
10414 ),
10415 )
10416 .unwrap();
10417 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
10418 permissions.set_mode(0o755);
10419 std::fs::set_permissions(&hook, permissions).unwrap();
10420 test_git(&path, &["config", "commit.gpgsign", "true"]);
10421 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
10422 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
10423 test_git(&path, &["add", "managed.txt"]);
10424
10425 repo.commit_staged_changes(&path, "record managed change")
10426 .unwrap();
10427
10428 assert!(!marker.exists());
10429 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
10430 }
10431
10432 #[test]
10433 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
10434 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
10435 std::fs::write(
10436 path.join(".gitattributes"),
10437 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
10438 )
10439 .unwrap();
10440 test_git(&path, &["add", ".gitattributes"]);
10441 test_git(&path, &["commit", "-m", "select automatic text"]);
10442 test_git(&path, &["config", "core.autocrlf", "true"]);
10443
10444 assert!(repository_has_recoverable_work(&path, true).unwrap());
10445 }
10446
10447 #[test]
10448 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
10449 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
10450 std::fs::write(
10451 path.join(".gitattributes"),
10452 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
10453 )
10454 .unwrap();
10455 test_git(&path, &["add", ".gitattributes"]);
10456 test_git(&path, &["commit", "-m", "select ident expansion"]);
10457 std::fs::write(path.join("README.md"), "seed\n").unwrap();
10458
10459 assert!(repository_has_recoverable_work(&path, true).unwrap());
10460 }
10461
10462 #[test]
10468 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
10469 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
10470 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
10471 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10472 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
10473 std::fs::write(
10474 path.join("bindings/uniffi-bindgen/Cargo.lock"),
10475 "# generated\n",
10476 )
10477 .unwrap();
10478
10479 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10480 .expect("a path the project ignores is the call's own output");
10481 }
10482
10483 #[test]
10484 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
10485 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
10486 exclude_paths(&repo, &["target/"]);
10487 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10488 std::fs::create_dir_all(path.join("target/package")).unwrap();
10489 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
10490
10491 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10492 .expect("an ignored attribute file governs only ignored paths");
10493 assert!(
10494 !repo.has_uncommitted_changes(&path).unwrap(),
10495 "ignored dependency attributes must not stop the next review"
10496 );
10497 }
10498
10499 #[test]
10500 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
10501 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
10502 std::fs::create_dir_all(path.join("source")).unwrap();
10503 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
10504
10505 assert!(repo.has_uncommitted_changes(&path).unwrap());
10506 }
10507
10508 #[test]
10509 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
10510 for source in ["repository", "nested", "local", "global"] {
10511 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
10512 std::fs::create_dir_all(path.join("manager")).unwrap();
10513 match source {
10514 "repository" => {
10515 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
10516 .unwrap();
10517 test_git(&path, &["add", ".gitignore"]);
10518 test_git(&path, &["commit", "-m", "ignore artifacts"]);
10519 }
10520 "nested" => {
10521 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
10522 test_git(&path, &["add", "manager/.gitignore"]);
10523 test_git(&path, &["commit", "-m", "ignore artifacts"]);
10524 }
10525 "local" => exclude_paths(&repo, &["manager/scratch/"]),
10526 "global" => {
10527 let excludes = repo.root().parent().unwrap().join("global-ignore");
10528 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
10529 test_git(
10530 &path,
10531 &["config", "core.excludesFile", excludes.to_str().unwrap()],
10532 );
10533 }
10534 _ => unreachable!(),
10535 }
10536 let output = path.join("manager/scratch");
10537 std::fs::create_dir_all(&output).unwrap();
10538 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
10539 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
10540 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10541 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
10542 std::fs::remove_file(output.join(".DS_Store")).unwrap();
10543 for file in [
10544 ".gitattributes",
10545 "Cargo.lock",
10546 "font.woff2",
10547 "bundle.js",
10548 "cache.db",
10549 ] {
10550 std::fs::write(output.join(file), "generated\n").unwrap();
10551 }
10552 #[cfg(unix)]
10553 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
10554 let nested = output.join("dependency");
10555 std::fs::create_dir_all(&nested).unwrap();
10556 test_git(&nested, &["init"]);
10557 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10558 .unwrap_or_else(|error| panic!("{source}: {error}"));
10559 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10560
10561 let baseline = repo.worktree_baseline(&path).unwrap();
10562 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
10563 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
10564 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
10565 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
10566 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
10567 assert!(repo
10568 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
10569 .unwrap_or_else(|error| panic!("{source}: {error}")));
10570 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10571 assert!(test_git(
10572 &path,
10573 &[
10574 "ls-tree",
10575 "-r",
10576 "--name-only",
10577 "HEAD",
10578 "--",
10579 "manager/scratch"
10580 ]
10581 )
10582 .is_empty());
10583 assert!(
10584 repository_has_recoverable_work(&path, true).unwrap(),
10585 "{source}"
10586 );
10587 repo.release_review_worktree(945);
10588 assert_eq!(
10589 "LOCAL=updated\n",
10590 std::fs::read_to_string(output.join(".env.local")).unwrap()
10591 );
10592 assert!(nested.join(".git").exists(), "{source}");
10593 }
10594 }
10595
10596 #[test]
10600 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
10601 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
10602 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10603 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
10604
10605 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10606 .expect("spar's own marker is not something a call did");
10607 }
10608
10609 #[test]
10610 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
10611 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
10612
10613 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
10614 .expect("a path a build removed is not an unreadable worktree");
10615
10616 assert_eq!(vanished_untracked_file(), gone);
10617 }
10618
10619 #[test]
10622 fn a_vanished_path_is_not_recorded_as_absent() {
10623 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
10624 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10625 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10626 std::fs::remove_file(path.join("notes.txt")).unwrap();
10627
10628 let error = repo
10629 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10630 .unwrap_err();
10631
10632 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10633 }
10634
10635 fn exclude_paths(repo: &Repo, lines: &[&str]) {
10636 use std::io::Write;
10637 let path = repo.root().join(".git").join("info").join("exclude");
10638 let mut file = std::fs::OpenOptions::new()
10639 .create(true)
10640 .append(true)
10641 .open(&path)
10642 .unwrap();
10643 for line in lines {
10644 writeln!(file, "{line}").unwrap();
10645 }
10646 }
10647
10648 #[test]
10649 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
10650 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
10651 exclude_paths(&repo, &["dist/"]);
10652 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10653 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10654 let baseline = repo.worktree_baseline(&path).unwrap();
10655 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10656 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10657 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10658 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10659
10660 assert!(repo
10661 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10662 .unwrap());
10663 assert_eq!(
10664 "tracked change\n",
10665 std::fs::read_to_string(path.join("README.md")).unwrap()
10666 );
10667 }
10668
10669 #[test]
10670 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10671 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10672 exclude_paths(&repo, &["dist/"]);
10673 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10674 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10675 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10676 let baseline = repo.worktree_baseline(&path).unwrap();
10677 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10678 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10679 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10680 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10681
10682 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10683 repo.refuse_changed_existing_untracked(&path, &baseline)
10684 .unwrap();
10685 }
10686
10687 #[test]
10688 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10689 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10690 exclude_paths(&repo, &["dist/"]);
10691 std::fs::create_dir_all(path.join("dist")).unwrap();
10692 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10693 let baseline = repo.worktree_baseline(&path).unwrap();
10694 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10695 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10696
10697 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10698 repo.refuse_changed_existing_untracked(&path, &baseline)
10699 .unwrap();
10700 }
10701
10702 #[test]
10711 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10712 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10713 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10714 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10715 test_git(
10716 repo.root(),
10717 &[
10718 "worktree",
10719 "add",
10720 "-b",
10721 "issue-950",
10722 held.to_str().unwrap(),
10723 "main",
10724 ],
10725 );
10726
10727 let found = repo
10728 .worktree_holding("issue-950")
10729 .expect("the branch is checked out somewhere");
10730
10731 assert!(found.ends_with("issue-950"), "{}", found.display());
10732 assert_eq!(None, repo.worktree_holding("issue-951"));
10733 }
10734
10735 #[test]
10736 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10737 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10738 exclude_paths(&repo, &["manager/public/assets/"]);
10739 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10740 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10741 std::fs::write(&bundle, "first build\n").unwrap();
10742 let baseline = repo.worktree_baseline(&path).unwrap();
10743 std::fs::write(&bundle, "second build\n").unwrap();
10744 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10745
10746 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10747 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10748 }
10749
10750 #[test]
10753 fn a_file_a_reviewer_created_is_moved_aside_and_the_checkout_is_whole_again() {
10754 let (_fixture, repo, path, checkpoint) = review_fixture("scratch-aside", 945);
10755 std::fs::create_dir_all(path.join("tests")).unwrap();
10756 std::fs::write(path.join("tests/zz-repro.test.ts"), "repro\n").unwrap();
10757
10758 repo.set_aside_review_scratch(&path, &checkpoint, 945);
10759
10760 assert!(!path.join("tests/zz-repro.test.ts").exists());
10761 let shelves: Vec<PathBuf> = std::fs::read_dir(repo.root().join(".spar/scratch"))
10762 .unwrap()
10763 .flatten()
10764 .map(|entry| entry.path())
10765 .collect();
10766 assert_eq!(1, shelves.len(), "{shelves:?}");
10767 assert_eq!(
10768 "repro\n",
10769 std::fs::read_to_string(shelves[0].join("tests/zz-repro.test.ts")).unwrap()
10770 );
10771 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10772 .unwrap();
10773 }
10774
10775 #[test]
10779 fn only_a_file_that_was_not_there_is_moved_aside() {
10780 let (_fixture, repo, path, _checkpoint) = review_fixture("scratch-only-new", 946);
10781 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10782 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10783 std::fs::write(path.join("README.md"), "rewritten\n").unwrap();
10784 std::fs::write(path.join("notes.txt"), "after\n").unwrap();
10785
10786 repo.set_aside_review_scratch(&path, &checkpoint, 946);
10787
10788 assert_eq!(
10789 "rewritten\n",
10790 std::fs::read_to_string(path.join("README.md")).unwrap()
10791 );
10792 assert_eq!(
10793 "after\n",
10794 std::fs::read_to_string(path.join("notes.txt")).unwrap()
10795 );
10796 assert!(!repo.root().join(".spar/scratch").exists());
10797 let error = repo
10798 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10799 .unwrap_err();
10800 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10801 assert!(error.to_string().contains("README.md"), "{error}");
10802 assert!(error.to_string().contains("notes.txt"), "{error}");
10803 }
10804
10805 #[test]
10806 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10807 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10808 exclude_paths(&repo, &["dist/"]);
10809 let baseline = repo.worktree_baseline(&path).unwrap();
10810 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10811
10812 let error = repo
10813 .refuse_read_only_leavings(&path, &baseline)
10814 .unwrap_err();
10815
10816 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10817 assert!(error.to_string().contains("scratch.md"), "{error}");
10818 }
10819
10820 #[test]
10821 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10822 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10823 exclude_paths(&repo, &["dist/", ".env.local"]);
10824 std::fs::create_dir_all(path.join("dist")).unwrap();
10825 let baseline = repo.worktree_baseline(&path).unwrap();
10826 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10827 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10828
10829 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10830 assert!(repository_has_recoverable_work(&path, true).unwrap());
10831 }
10832
10833 #[test]
10834 fn a_read_only_inspection_may_rebuild_generated_output() {
10835 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10836 exclude_paths(&repo, &["dist/"]);
10837 std::fs::create_dir_all(path.join("dist")).unwrap();
10838 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10839 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10840 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10841 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10842
10843 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10844 .unwrap();
10845 }
10846
10847 #[test]
10848 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10849 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10850 exclude_paths(&repo, &["dist/", ".env.local"]);
10851 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10852 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10853 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10854
10855 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10856 .unwrap();
10857 }
10858
10859 #[test]
10860 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10861 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10862 exclude_paths(&repo, &["dist/", ".env.local"]);
10863 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10864 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10865 std::fs::remove_file(path.join(".env.local")).unwrap();
10866
10867 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10868 .unwrap();
10869 }
10870
10871 #[test]
10872 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10873 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10874 exclude_paths(&repo, &["dist/"]);
10875 std::fs::write(path.join("notes.md"), "before\n").unwrap();
10876 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10877 std::fs::write(path.join("notes.md"), "after\n").unwrap();
10878
10879 let error = repo
10880 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10881 .unwrap_err();
10882
10883 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10884 assert!(error.to_string().contains("notes.md"), "{error}");
10885 }
10886
10887 #[test]
10888 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10889 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10890 exclude_paths(&repo, &["dist/", "local.env"]);
10891 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10892 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10893 std::fs::write(
10896 repo.root().join(".git").join("info").join("exclude"),
10897 "dist/\n",
10898 )
10899 .unwrap();
10900
10901 let error = repo
10902 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10903 .unwrap_err();
10904
10905 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10906 assert!(error.to_string().contains("local.env"), "{error}");
10907 }
10908
10909 #[test]
10910 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10911 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10912 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10913 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10914
10915 let error = repo
10916 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10917 .unwrap_err();
10918
10919 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10920 assert!(error.to_string().contains("README.md"), "{error}");
10921 }
10922
10923 #[test]
10924 fn build_output_alone_does_not_keep_a_worktree() {
10925 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10926 exclude_paths(&repo, &["target/", "dist/"]);
10927 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10928 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10929 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10930 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10931
10932 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10933 repo.release_review_worktree(933);
10934
10935 assert!(!path.exists());
10936 }
10937
10938 #[test]
10939 fn finder_metadata_alone_does_not_keep_a_worktree() {
10940 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10941 exclude_paths(&repo, &[".DS_Store"]);
10942 std::fs::create_dir_all(path.join("src")).unwrap();
10943 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10944 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10945
10946 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10947 repo.release_review_worktree(937);
10948
10949 assert!(!path.exists());
10950 }
10951
10952 #[test]
10953 fn a_fetch_record_alone_does_not_keep_a_worktree() {
10954 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10955 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10956 let git_dir = if raw.is_absolute() {
10957 raw
10958 } else {
10959 path.join(raw)
10960 };
10961 let head = test_git(&path, &["rev-parse", "HEAD"]);
10962 std::fs::write(
10963 git_dir.join("FETCH_HEAD"),
10964 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10965 )
10966 .unwrap();
10967
10968 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10969 repo.release_review_worktree(938);
10970
10971 assert!(!path.exists());
10972 }
10973
10974 #[test]
10975 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10976 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10977 exclude_paths(&repo, &["target/", ".env.local"]);
10978 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10979 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10980 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10981
10982 assert!(repository_has_recoverable_work(&path, true).unwrap());
10983 repo.release_review_worktree(934);
10984
10985 assert_eq!(
10986 "TOKEN=keep me\n",
10987 std::fs::read_to_string(path.join(".env.local")).unwrap()
10988 );
10989 }
10990
10991 #[test]
10992 fn a_repository_nested_in_build_output_keeps_a_worktree() {
10993 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10994 exclude_paths(&repo, &["node_modules/"]);
10995 let nested = path.join("node_modules/local-dep");
10996 std::fs::create_dir_all(&nested).unwrap();
10997 test_git(&nested, &["init"]);
10998 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10999
11000 assert!(repository_has_recoverable_work(&path, true).unwrap());
11001 repo.release_review_worktree(935);
11002
11003 assert!(nested.join(".git").exists());
11004 }
11005
11006 #[test]
11007 fn an_ordinary_untracked_file_keeps_a_worktree() {
11008 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
11009 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
11010
11011 assert!(repository_has_recoverable_work(&path, true).unwrap());
11012 repo.release_review_worktree(936);
11013
11014 assert_eq!(
11015 "somebody's notes\n",
11016 std::fs::read_to_string(path.join("notes.md")).unwrap()
11017 );
11018 }
11019
11020 #[test]
11021 fn a_legacy_crlf_checkout_is_retained_conservatively() {
11022 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
11023 std::fs::write(
11024 path.join(".gitattributes"),
11025 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
11026 )
11027 .unwrap();
11028 test_git(&path, &["add", ".gitattributes"]);
11029 test_git(&path, &["commit", "-m", "select legacy line endings"]);
11030
11031 assert!(repository_has_recoverable_work(&path, true).unwrap());
11032 }
11033
11034 #[test]
11035 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
11036 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
11037 let nested = path.join("tracked");
11038 std::fs::create_dir_all(&nested).unwrap();
11039 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
11040 test_git(&path, &["add", "tracked/seed.txt"]);
11041 test_git(&path, &["commit", "-m", "add tracked directory"]);
11042 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
11043 test_git(&nested, &["init"]);
11044
11045 let error = repo
11046 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
11047 .unwrap_err();
11048
11049 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11050 assert!(error.to_string().contains("Git entry"), "{error}");
11051 assert!(nested.join(".git").exists());
11052 }
11053
11054 #[test]
11059 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
11060 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
11061 exclude_paths(&repo, &["target/"]);
11062 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
11063 let repro = path.join("target/pr918-repro");
11064 std::fs::create_dir_all(&repro).unwrap();
11065 test_git(&repro, &["init"]);
11066 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
11067
11068 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
11069 .unwrap();
11070
11071 assert!(repository_has_recoverable_work(&path, true).unwrap());
11072 assert!(repro.join(".git").exists());
11073 }
11074
11075 #[test]
11079 fn a_repository_inside_a_nested_checkout_is_left_alone() {
11080 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
11081 exclude_paths(&repo, &["target/"]);
11082 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
11083 let repro = path.join("target/repro");
11084 let vendored = repro.join("vendor/dep");
11085 std::fs::create_dir_all(&vendored).unwrap();
11086 test_git(&repro, &["init"]);
11087 test_git(&vendored, &["init"]);
11088
11089 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
11090 .unwrap();
11091
11092 assert!(vendored.join(".git").exists());
11093 }
11094
11095 #[test]
11096 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
11097 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
11098
11099 let state = ignored_untracked_state(repo.root()).unwrap();
11100
11101 let relative = path.strip_prefix(repo.root()).unwrap();
11102 assert!(
11103 state.files.contains_key(relative),
11104 "{:?}",
11105 state.files.keys().collect::<Vec<_>>()
11106 );
11107 assert!(state.is_ignored(relative));
11108 }
11109
11110 #[test]
11111 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
11112 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
11113 let baseline = repo.worktree_baseline(repo.root()).unwrap();
11114 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
11115 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
11116
11117 repo.refuse_new_ignored_files(repo.root(), &baseline)
11118 .unwrap();
11119 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
11120 .unwrap();
11121 }
11122
11123 #[test]
11124 fn deleting_a_resident_worktree_during_a_call_is_refused() {
11125 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
11126 let baseline = repo.worktree_baseline(repo.root()).unwrap();
11127 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
11128 std::fs::remove_dir_all(&path).unwrap();
11129
11130 let read_error = repo
11131 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
11132 .unwrap_err();
11133 assert!(
11134 read_error.to_string().contains("review-932"),
11135 "{read_error}"
11136 );
11137 let error = repo
11138 .refuse_new_ignored_files(repo.root(), &baseline)
11139 .unwrap_err();
11140
11141 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11142 assert!(error.to_string().contains("review-932"), "{error}");
11143 }
11144
11145 #[test]
11146 fn a_nested_repository_record_is_read_as_a_plain_path() {
11147 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
11148 assert_eq!(Path::new("vendor/checkout"), path);
11149 assert!(nested);
11150
11151 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
11152 assert_eq!(Path::new("vendor/notes.txt"), path);
11153 assert!(!nested);
11154
11155 assert!(untracked_record(b"/", "untracked").is_err());
11156 }
11157
11158 #[cfg(unix)]
11159 #[test]
11160 fn a_non_utf8_git_path_is_preserved_without_loss() {
11161 use std::os::unix::ffi::OsStrExt;
11162
11163 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
11164
11165 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
11166 }
11167
11168 #[test]
11169 fn guarded_merge_pins_the_reviewed_head() {
11170 let args = merge_pr_args("36", Some("abc123"), true);
11171 assert_eq!(
11172 vec![
11173 "pr",
11174 "merge",
11175 "36",
11176 "--squash",
11177 "--delete-branch",
11178 "--match-head-commit",
11179 "abc123"
11180 ],
11181 args
11182 );
11183 }
11184
11185 #[test]
11186 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
11187 let pr = PrRef {
11188 number: 7,
11189 url: "https://example.test/pull/7".into(),
11190 title: "part one".into(),
11191 };
11192 let result = reconcile_pr_creation(
11193 "split-34-1",
11194 Err(crate::error::SparError::new("connection lost")),
11195 Ok(Some(pr)),
11196 )
11197 .unwrap();
11198 assert_eq!(7, result.number);
11199 }
11200
11201 #[test]
11202 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
11203 let error = reconcile_pr_creation(
11204 "split-34-1",
11205 Err(crate::error::SparError::new("permission denied")),
11206 Ok(None),
11207 )
11208 .unwrap_err();
11209 assert!(error.to_string().contains("permission denied"), "{error}");
11210 }
11211
11212 #[test]
11213 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
11214 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
11215 assert!(pr_for_base(text, "split-34-2", "split-34-1")
11216 .unwrap()
11217 .is_none());
11218 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
11219 assert_eq!(7, found.number);
11220 }
11221
11222 #[test]
11223 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
11224 let result = reconcile_comment_post(
11225 34,
11226 "the summary",
11227 crate::error::SparError::new("connection lost"),
11228 Ok(vec![serde_json::json!({"body": "the summary"})]),
11229 );
11230 assert!(result.is_ok(), "{result:?}");
11231 }
11232
11233 #[test]
11234 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
11235 let error = reconcile_comment_post(
11236 34,
11237 "the summary",
11238 crate::error::SparError::new("connection lost"),
11239 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
11240 )
11241 .unwrap_err();
11242 assert_eq!("connection lost", error.to_string());
11243 }
11244
11245 #[test]
11246 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
11247 let error = reconcile_comment_post(
11248 34,
11249 "the summary",
11250 crate::error::SparError::new("connection lost"),
11251 Err(crate::error::SparError::new("comments unavailable")),
11252 )
11253 .unwrap_err();
11254 assert!(
11255 error.to_string().contains("could not be verified"),
11256 "{error}"
11257 );
11258 assert!(
11259 error.to_string().contains("comments unavailable"),
11260 "{error}"
11261 );
11262 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11263 assert!(!error.worth_retrying());
11264 }
11265
11266 #[test]
11267 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
11268 let result = reconcile_issue_edit(
11269 34,
11270 "wanted body",
11271 crate::error::SparError::new("connection lost"),
11272 Ok("wanted body".to_string()),
11273 );
11274 assert!(result.is_ok(), "{result:?}");
11275 }
11276
11277 #[test]
11278 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
11279 let error = reconcile_issue_edit(
11280 34,
11281 "wanted body",
11282 crate::error::SparError::new("connection lost"),
11283 Err(crate::error::SparError::new("issue unavailable")),
11284 )
11285 .unwrap_err();
11286 assert!(
11287 error.to_string().contains("could not be verified"),
11288 "{error}"
11289 );
11290 assert!(error.to_string().contains("issue unavailable"), "{error}");
11291 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11292 assert!(!error.worth_retrying());
11293 }
11294
11295 #[test]
11296 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
11297 let found = ExistingIssue {
11298 number: 101,
11299 url: "https://example.test/issues/101".into(),
11300 title: "child".into(),
11301 body: "body".into(),
11302 open: true,
11303 };
11304 let url = reconcile_issue_creation(
11305 "child",
11306 Err(crate::error::SparError::new("connection lost")),
11307 Ok(Some(found)),
11308 )
11309 .unwrap();
11310 assert_eq!("https://example.test/issues/101", url);
11311 }
11312
11313 #[test]
11314 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
11315 let error = reconcile_issue_creation(
11316 "child",
11317 Err(crate::error::SparError::new("permission denied")),
11318 Ok(None),
11319 )
11320 .unwrap_err();
11321 assert!(error.to_string().contains("permission denied"), "{error}");
11322 }
11323
11324 #[test]
11325 fn an_unverifiable_issue_creation_is_marked_uncertain() {
11326 let error = reconcile_issue_creation(
11327 "child",
11328 Err(crate::error::SparError::new("connection lost")),
11329 Err(crate::error::SparError::new("issues unavailable")),
11330 )
11331 .unwrap_err();
11332 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11333 assert!(!error.worth_retrying());
11334 }
11335
11336 #[test]
11337 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
11338 let result = reconcile_failed_split_push(
11339 "split-34-1",
11340 crate::error::SparError::new("connection lost"),
11341 Ok("abc123\n".into()),
11342 Ok("abc123\trefs/heads/split-34-1\n".into()),
11343 );
11344 assert!(result.is_ok(), "{result:?}");
11345 }
11346
11347 #[test]
11348 fn a_split_push_collision_is_definite_and_never_overwrites() {
11349 let error = reconcile_failed_split_push(
11350 "split-34-1",
11351 crate::error::SparError::new("lease rejected"),
11352 Ok("abc123\n".into()),
11353 Ok("def456\trefs/heads/split-34-1\n".into()),
11354 )
11355 .unwrap_err();
11356 assert!(!error.retain_worktree());
11357 assert!(
11358 error.to_string().contains("Nothing was overwritten"),
11359 "{error}"
11360 );
11361 }
11362
11363 #[test]
11364 fn an_unreadable_split_push_result_keeps_the_worktree() {
11365 let error = reconcile_failed_split_push(
11366 "split-34-1",
11367 crate::error::SparError::new("connection lost"),
11368 Ok("abc123\n".into()),
11369 Err(crate::error::SparError::new("origin unavailable")),
11370 )
11371 .unwrap_err();
11372 assert!(error.retain_worktree());
11373 assert!(error.to_string().contains("could not confirm"), "{error}");
11374 }
11375
11376 #[test]
11380 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
11381 let repo = repo_for_titles();
11382 for raw in [
11383 "Retry loop spins \u{2014} Retry-After parses to zero",
11384 "plain title",
11385 " spread over\nlines ",
11386 "\u{1F916} Generated with something",
11387 &format!("a \u{2014} {}", "very long title ".repeat(20)),
11388 &"x".repeat(300),
11389 &format!("{} \u{2014} end", "y".repeat(88)),
11390 &{
11395 let tail = "a\u{2014}b c\u{2014}d";
11396 let pad = Style::default().max_title_chars - tail.chars().count();
11397 format!("{}{tail}", "w".repeat(pad))
11398 },
11399 ] {
11400 let once = repo.clean_title(raw).unwrap();
11401 let twice = repo.clean_title(&once).unwrap();
11402 assert_eq!(once, twice, "not idempotent for {raw:?}");
11403 assert!(
11404 once.chars().count() <= repo.style.max_title_chars,
11405 "over budget: {once:?}"
11406 );
11407 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
11408 }
11409 }
11410
11411 #[test]
11412 fn a_title_with_an_em_dash_survives_as_readable_text() {
11413 let repo = repo_for_titles();
11414 assert_eq!(
11415 "Retry loop spins, Retry-After parses to zero",
11416 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
11417 .unwrap()
11418 );
11419 }
11420
11421 #[test]
11422 fn sh_quote_survives_a_quote() {
11423 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
11424 }
11425
11426 #[test]
11427 fn sh_quote_wraps_a_space() {
11428 assert_eq!(
11429 "'/Applications/My App/spar'",
11430 sh_quote("/Applications/My App/spar")
11431 );
11432 }
11433
11434 #[test]
11435 fn finished_states_are_recognised_case_insensitively() {
11436 assert!(is_finished("MERGED"));
11437 assert!(is_finished("closed"));
11438 assert!(!is_finished("OPEN"));
11439 assert!(!is_finished(""));
11440 }
11441
11442 fn state() -> PersistedState {
11443 PersistedState {
11444 version: 1,
11445 checkpoint: 0,
11446 round: 4,
11447 next_actor: "codex".into(),
11448 status: Status::Pending,
11449 pr_head: "abc123".into(),
11450 ledger: Ledger::new(),
11451 filed: vec![],
11452 open_findings: vec![Finding {
11453 severity: Severity::Blocking,
11454 title: "Unchecked error".into(),
11455 detail: "the failure is discarded".into(),
11456 file: "src/a.rs:12".into(),
11457 ..Finding::default()
11458 }],
11459 disputes: vec![Dispute {
11460 title: "Retry limit".into(),
11461 file: "src/net.rs".into(),
11462 reasoning: "the caller already bounds it".into(),
11463 }],
11464 noted: vec![Finding {
11465 severity: Severity::NonBlocking,
11466 title: "Timeout is fixed".into(),
11467 file: "src/config.rs".into(),
11468 ..Finding::default()
11469 }],
11470 }
11471 }
11472
11473 #[test]
11474 fn a_state_comment_round_trips() {
11475 let body = format!(
11476 "{STATE_MARKER}\n{}\n-->",
11477 serde_json::to_string(&state()).unwrap()
11478 );
11479 let back = parse_state_comment(&body).unwrap();
11480 assert_eq!(4, back.round);
11481 assert_eq!("codex", back.next_actor);
11482 assert_eq!("abc123", back.pr_head);
11483 assert_eq!("Unchecked error", back.open_findings[0].title);
11484 assert_eq!("src/net.rs", back.disputes[0].file);
11485 assert_eq!("Timeout is fixed", back.noted[0].title);
11486 }
11487
11488 #[test]
11489 fn old_state_without_new_lists_still_parses() {
11490 let body = format!(
11491 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
11492 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11493 );
11494 let back = parse_state_comment(&body).expect("old state");
11495 assert!(back.open_findings.is_empty());
11496 assert!(back.disputes.is_empty());
11497 assert!(back.noted.is_empty());
11498 assert!(back.pr_head.is_empty());
11499 assert_eq!(0, back.checkpoint);
11500 }
11501
11502 #[test]
11503 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
11504 let mut local = state();
11505 local.pr_head = "old".into();
11506 local.round = 9;
11507 let mut remote = state();
11508 remote.pr_head = "current".into();
11509 remote.round = 4;
11510
11511 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11512 assert_eq!("current", chosen.pr_head);
11513 assert_eq!(4, chosen.round);
11514 }
11515
11516 #[test]
11517 fn checkpoint_order_breaks_same_round_ties() {
11518 let mut local = state();
11519 local.pr_head = "current".into();
11520 local.round = 4;
11521 local.checkpoint = 8;
11522 let mut remote = local.clone();
11523 remote.checkpoint = 7;
11524 remote.open_findings.clear();
11525
11526 let chosen = choose_state_for_head(vec![local], "current").unwrap();
11527 assert_eq!(8, chosen.checkpoint);
11528
11529 let mut local = state();
11530 local.pr_head = "current".into();
11531 local.round = 4;
11532 local.checkpoint = 8;
11533 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
11534 assert_eq!(8, chosen.checkpoint);
11535 }
11536
11537 #[test]
11538 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
11539 let mut local = state();
11540 local.pr_head = "current".into();
11541 local.round = 4;
11542 local.open_findings.push(Finding {
11543 title: "local checkpoint".into(),
11544 ..Finding::default()
11545 });
11546 let mut remote = state();
11547 remote.pr_head = "current".into();
11548 remote.round = 4;
11549
11550 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11551 assert_eq!(
11552 "local checkpoint",
11553 chosen.open_findings.last().unwrap().title
11554 );
11555 }
11556
11557 #[test]
11559 fn the_state_block_is_an_html_comment() {
11560 let body = format!(
11561 "{STATE_MARKER}\n{}\n-->",
11562 serde_json::to_string(&state()).unwrap()
11563 );
11564 assert!(body.starts_with("<!--"));
11565 assert!(body.trim_end().ends_with("-->"));
11566 assert!(!body[..body.find('{').unwrap()].contains("-->"));
11567 }
11568
11569 #[test]
11570 fn an_unrelated_json_block_is_not_state() {
11571 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
11572 }
11573
11574 #[test]
11581 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
11582 let block = format!(
11583 "{STATE_MARKER}\n{}\n-->",
11584 serde_json::to_string(&state()).unwrap()
11585 );
11586 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
11587 assert!(
11588 is_state_comment(&block, "Spar-Bot", "spar-bot"),
11589 "a login differs only in case"
11590 );
11591
11592 assert!(
11593 !is_state_comment(
11594 "why is there a `<!-- spar:state` block on this PR?",
11595 "maintainer",
11596 "spar-bot"
11597 ),
11598 "a question about the block was treated as the block"
11599 );
11600 assert!(
11601 !is_state_comment(&block, "maintainer", "spar-bot"),
11602 "somebody else's comment is not spar's to rewrite"
11603 );
11604 assert!(
11605 !is_state_comment(
11606 &format!("here is what spar writes:\n\n{block}"),
11607 "spar-bot",
11608 "spar-bot"
11609 ),
11610 "the marker has to open the body, not appear in it"
11611 );
11612 assert!(
11613 !is_state_comment(
11614 &format!("{STATE_MARKER}\n{{not json\n-->"),
11615 "spar-bot",
11616 "spar-bot"
11617 ),
11618 "a block that does not parse is not state"
11619 );
11620 assert!(
11621 !is_state_comment(&block, "spar-bot", ""),
11622 "an unknown viewer proves nothing about who wrote it"
11623 );
11624 }
11625
11626 #[test]
11627 fn a_malformed_state_comment_is_none_not_a_panic() {
11628 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
11629 }
11630
11631 #[test]
11632 fn atomic_write_leaves_no_temp_file() {
11633 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
11634 let _ = std::fs::remove_dir_all(&dir);
11635 let path = dir.join("state").join("pr-7.json");
11636 write_json_atomic(&path, &state()).unwrap();
11637 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
11638 .unwrap()
11639 .flatten()
11640 .filter_map(|e| e.file_name().to_str().map(str::to_string))
11641 .collect();
11642 assert_eq!(vec!["pr-7.json".to_string()], files);
11643 let _ = std::fs::remove_dir_all(&dir);
11644 }
11645
11646 #[test]
11647 fn atomic_write_overwrites_rather_than_accumulating() {
11648 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
11649 let _ = std::fs::remove_dir_all(&dir);
11650 let path = dir.join("pr-7.json");
11651 for round in 1..4 {
11652 let mut s = state();
11653 s.round = round;
11654 write_json_atomic(&path, &s).unwrap();
11655 }
11656 let back: PersistedState =
11657 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11658 assert_eq!(3, back.round);
11659 let _ = std::fs::remove_dir_all(&dir);
11660 }
11661
11662 #[test]
11663 fn style_from_env_defaults_to_enforcing() {
11664 std::env::remove_var("SPAR_BAN_EM_DASH");
11665 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
11666 let style = style_from_env();
11667 assert!(style.ban_em_dash && style.ban_ai_attribution);
11668 assert!(
11669 !style.terse,
11670 "the commit filter must not truncate a commit message"
11671 );
11672 }
11673
11674 #[test]
11679 fn a_push_denial_is_read_from_either_transport() {
11680 let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
11681 fatal: Could not read from remote repository.\n\n\
11682 Please make sure you have the correct access rights\n\
11683 and the repository exists.";
11684 let https = "remote: Permission to example/project.git denied to someone.\n\
11685 fatal: unable to access 'https://github.com/example/project.git/': The \
11686 requested URL returned error: 403";
11687 let denied = Some("Permission to example/project.git denied to someone.");
11688 assert_eq!(denied, push_denial(ssh));
11689 assert_eq!(denied, push_denial(https));
11690
11691 let stale = "To github.com:example/project.git\n \
11692 ! [rejected] HEAD -> issue-7 (stale info)\n\
11693 error: failed to push some refs to 'github.com:example/project.git'";
11694 assert_eq!(None, push_denial(stale));
11695
11696 let err = push_error(
11697 &PushTarget::Origin,
11698 "issue-7",
11699 &crate::error::SparError::new(ssh),
11700 );
11701 assert_eq!(
11702 "could not push to origin/issue-7. Permission to example/project.git denied to \
11703 someone.",
11704 err.to_string()
11705 );
11706 }
11707
11708 #[cfg(unix)]
11712 fn denied_fixture(
11713 tag: &str,
11714 fork_when_denied: bool,
11715 ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11716 use std::os::unix::fs::PermissionsExt;
11717 use std::sync::atomic::{AtomicU32, Ordering};
11718 static NEXT: AtomicU32 = AtomicU32::new(0);
11719 let id = NEXT.fetch_add(1, Ordering::Relaxed);
11720 let root =
11721 std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11722 let origin = root.join("origin.git");
11723 let fork = root.join("fork.git");
11724 let work = root.join("work");
11725 for dir in [&origin, &fork, &work] {
11726 std::fs::create_dir_all(dir).unwrap();
11727 }
11728 test_git(&origin, &["init", "--bare", "-b", "main"]);
11729 test_git(&fork, &["init", "--bare", "-b", "main"]);
11730 test_git(&work, &["init", "-b", "main"]);
11731 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11732 test_git(&work, &["config", "user.name", "spar test"]);
11733 test_git(&work, &["config", "commit.gpgsign", "false"]);
11734 std::fs::write(work.join("README.md"), "seed\n").unwrap();
11735 test_git(&work, &["add", "."]);
11736 test_git(&work, &["commit", "-m", "seed"]);
11737 test_git(
11738 &work,
11739 &["remote", "add", "origin", origin.to_str().unwrap()],
11740 );
11741 test_git(&work, &["push", "-q", "origin", "main"]);
11742 let hook = origin.join("hooks").join("pre-receive");
11744 std::fs::write(
11745 &hook,
11746 "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11747 )
11748 .unwrap();
11749 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11750 test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11751 std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11752 test_git(&work, &["add", "."]);
11753 test_git(&work, &["commit", "-m", "Fix it"]);
11754
11755 let mut text =
11756 String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11757 if !fork_when_denied {
11758 text.push_str("[loop]\nfork_when_denied = false\n");
11759 }
11760 let cfg = crate::config::parse(&text).unwrap();
11761 let repo = Repo::open(&work, &cfg).unwrap();
11762 repo.fork
11764 .set(Fork {
11765 owner: "someone".into(),
11766 full_name: "someone/project".into(),
11767 url: fork.to_str().unwrap().into(),
11768 })
11769 .unwrap();
11770 (ReviewFixture { root }, repo, work, origin, fork)
11771 }
11772
11773 #[cfg(unix)]
11774 #[test]
11775 fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11776 let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11777 let first = test_git(&work, &["rev-parse", "HEAD"]);
11778
11779 let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11780
11781 assert_eq!("someone:issue-7", target.head("issue-7"));
11782 assert_eq!(
11783 first.trim(),
11784 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11785 );
11786 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11787 .trim()
11788 .is_empty());
11789 assert_eq!(
11790 WriteSummary {
11791 attempted: 1,
11792 failed: 0
11793 },
11794 repo.write_summary(),
11795 "a refusal the fork answered is not a failed write"
11796 );
11797
11798 std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11801 test_git(&work, &["commit", "-qam", "Fix it again"]);
11802 repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11803 .unwrap();
11804 let second = test_git(&work, &["rev-parse", "HEAD"]);
11805 assert_eq!(
11806 second.trim(),
11807 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11808 );
11809 }
11810
11811 #[cfg(unix)]
11815 #[test]
11816 fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11817 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11818 test_git(
11819 &work,
11820 &[
11821 "push",
11822 "-q",
11823 fork.to_str().unwrap(),
11824 "main:refs/heads/issue-7",
11825 ],
11826 );
11827 let head = test_git(&work, &["rev-parse", "HEAD"]);
11828
11829 repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11830
11831 assert_eq!(
11832 head.trim(),
11833 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11834 );
11835 assert_eq!(0, repo.write_summary().failed);
11836 }
11837
11838 #[cfg(unix)]
11839 #[test]
11840 fn work_only_the_fork_has_is_not_published_over() {
11841 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11842 test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11843 std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11844 test_git(&work, &["add", "."]);
11845 test_git(&work, &["commit", "-qm", "Somebody's work"]);
11846 test_git(
11847 &work,
11848 &[
11849 "push",
11850 "-q",
11851 fork.to_str().unwrap(),
11852 "elsewhere:refs/heads/issue-7",
11853 ],
11854 );
11855 let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11856 test_git(&work, &["checkout", "-q", "issue-7"]);
11857
11858 let err = repo
11859 .publish_new_branch(&work, "issue-7", "main")
11860 .unwrap_err()
11861 .to_string();
11862
11863 assert!(
11864 err.contains(
11865 "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11866 ),
11867 "{err}"
11868 );
11869 assert!(err.contains("--delete issue-7"), "{err}");
11870 assert_eq!(
11871 theirs.trim(),
11872 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11873 );
11874 assert_eq!(
11875 WriteSummary {
11876 attempted: 1,
11877 failed: 1
11878 },
11879 repo.write_summary()
11880 );
11881 }
11882
11883 #[test]
11885 fn a_rejected_push_names_the_ref_it_rejected() {
11886 let stale = "To github.com:example/project.git\n \
11887 ! [rejected] HEAD -> issue-7 (stale info)\n\
11888 error: failed to push some refs to 'github.com:example/project.git'";
11889
11890 let err = push_error(
11891 &PushTarget::Origin,
11892 "issue-7",
11893 &crate::error::SparError::new(stale),
11894 )
11895 .to_string();
11896
11897 assert!(
11898 err.starts_with(
11899 "could not push to origin/issue-7. [rejected] HEAD -> issue-7 (stale info)"
11900 ),
11901 "{err}"
11902 );
11903 }
11904
11905 const COMMIT_REFS_RULES: &str = "To github.com:example/project.git\n \
11908 ! [remote rejected] HEAD -> issue-7 (fatal error in commit_refs_rules)\n\
11909 error: failed to push some refs to 'github.com:example/project.git'";
11910
11911 #[test]
11912 fn a_remote_failing_on_its_own_side_is_worth_pushing_again() {
11913 let transient = [
11914 (
11915 COMMIT_REFS_RULES,
11916 "[remote rejected] HEAD -> issue-7 (fatal error in commit_refs_rules)",
11917 ),
11918 (
11919 " ! [remote rejected] HEAD -> issue-7 (Internal Server Error)",
11920 "[remote rejected] HEAD -> issue-7 (Internal Server Error)",
11921 ),
11922 (
11923 "error: RPC failed; HTTP 502 curl 22 The requested URL returned error: 502\n\
11924 send-pack: unexpected disconnect while reading sideband packet\n\
11925 fatal: the remote end hung up unexpectedly",
11926 "error: RPC failed; HTTP 502 curl 22 The requested URL returned error: 502",
11927 ),
11928 (
11929 "error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: CANCEL (err 8)",
11930 "error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: CANCEL (err 8)",
11931 ),
11932 (
11933 "kex_exchange_identification: Connection closed by remote host\n\
11934 fatal: Could not read from remote repository.",
11935 "kex_exchange_identification: Connection closed by remote host",
11936 ),
11937 ];
11938 for (message, line) in transient {
11939 assert_eq!(Some(line), transient_push_failure(message), "{message}");
11940 }
11941
11942 let about_the_push = [
11943 "To github.com:example/project.git\n \
11945 ! [rejected] HEAD -> issue-7 (stale info)\n\
11946 error: failed to push some refs to 'github.com:example/project.git'",
11947 " ! [remote rejected] HEAD -> issue-7 (pre-receive hook declined)",
11949 "remote: error: GH013: Repository rule violations found for refs/heads/issue-7.\n \
11950 ! [remote rejected] HEAD -> issue-7 (push declined due to repository rule \
11951 violations)",
11952 "ERROR: Permission to example/project.git denied to someone.\n\
11954 fatal: Could not read from remote repository.",
11955 "error: RPC failed; HTTP 413 curl 22 The requested URL returned error: 413\n\
11958 fatal: the remote end hung up unexpectedly",
11959 "fatal: 'origin' does not appear to be a git repository\n\
11960 fatal: Could not read from remote repository.",
11961 ];
11962 for message in about_the_push {
11963 assert_eq!(None, transient_push_failure(message), "{message}");
11964 }
11965 }
11966
11967 fn scripted_push(
11969 answers: Vec<std::result::Result<(), crate::error::SparError>>,
11970 landed: bool,
11971 interrupted: bool,
11972 ) -> (Result<()>, usize, usize, Vec<u64>) {
11973 let mut answers = answers.into_iter();
11974 let mut pushes = 0;
11975 let mut checks = 0;
11976 let mut waited = Vec::new();
11977 let result = retry_transient_push(
11978 "origin/issue-7",
11979 || {
11980 pushes += 1;
11981 answers.next().expect("pushed more often than scripted")
11982 },
11983 || {
11984 checks += 1;
11985 landed
11986 },
11987 &PUSH_RETRY_WAITS,
11988 |wait| {
11989 waited.push(wait.as_secs());
11990 interrupted
11991 },
11992 );
11993 (result, pushes, checks, waited)
11994 }
11995
11996 fn github_fault() -> std::result::Result<(), crate::error::SparError> {
11997 Err(crate::error::SparError::new(COMMIT_REFS_RULES))
11998 }
11999
12000 #[test]
12001 fn a_push_the_remote_failed_on_its_side_is_tried_again() {
12002 let (result, pushes, checks, waited) =
12003 scripted_push(vec![github_fault(), Ok(())], false, false);
12004
12005 assert!(result.is_ok(), "{result:?}");
12006 assert_eq!(2, pushes);
12007 assert_eq!(1, checks, "the remote is reread before pushing again");
12008 assert_eq!(vec![10], waited);
12009 }
12010
12011 #[test]
12012 fn a_remote_that_keeps_failing_is_reported_after_the_last_try() {
12013 let (result, pushes, checks, waited) = scripted_push(
12014 vec![github_fault(), github_fault(), github_fault()],
12015 false,
12016 false,
12017 );
12018
12019 let err = result.unwrap_err().to_string();
12020 assert!(err.contains("commit_refs_rules"), "{err}");
12021 assert_eq!(3, pushes);
12022 assert_eq!(3, checks, "the last failure is reread too");
12023 assert_eq!(vec![10, 30], waited);
12024 }
12025
12026 #[test]
12027 fn a_push_that_landed_despite_the_failure_is_not_pushed_again() {
12028 let (result, pushes, _, waited) = scripted_push(vec![github_fault()], true, false);
12029
12030 assert!(result.is_ok(), "{result:?}");
12031 assert_eq!(1, pushes);
12032 assert!(waited.is_empty(), "{waited:?}");
12033 }
12034
12035 #[test]
12036 fn a_push_refused_for_itself_is_not_tried_again() {
12037 let stale = Err(crate::error::SparError::new(
12038 " ! [rejected] HEAD -> issue-7 (stale info)",
12039 ));
12040 let timed_out = Err(crate::error::SparError::timed_out(
12041 "fatal: the remote end hung up unexpectedly",
12042 ));
12043 for answer in [stale, timed_out] {
12044 let (result, pushes, checks, waited) = scripted_push(vec![answer], false, false);
12045
12046 assert!(result.is_err());
12047 assert_eq!((1, 0), (pushes, checks));
12048 assert!(waited.is_empty(), "{waited:?}");
12049 }
12050 }
12051
12052 #[test]
12053 fn a_stop_while_waiting_to_push_again_ends_the_retries() {
12054 let (result, pushes, _, waited) = scripted_push(vec![github_fault()], false, true);
12055
12056 assert!(result.is_err());
12057 assert_eq!(1, pushes);
12058 assert_eq!(vec![10], waited);
12059 }
12060
12061 #[test]
12064 fn a_push_the_remote_kept_failing_says_to_try_again_later() {
12065 let err = push_error(
12066 &PushTarget::Origin,
12067 "issue-7",
12068 &crate::error::SparError::new(COMMIT_REFS_RULES),
12069 )
12070 .to_string();
12071
12072 assert!(
12073 err.starts_with(
12074 "could not push to origin/issue-7. [remote rejected] HEAD -> issue-7 (fatal \
12075 error in commit_refs_rules)\nThe remote failed on its own side"
12076 ),
12077 "{err}"
12078 );
12079 assert!(err.ends_with("Try again later."), "{err}");
12080 assert!(!err.contains("Check push access"), "{err}");
12081 }
12082
12083 #[cfg(unix)]
12084 #[test]
12085 fn a_branch_is_known_to_have_landed_only_at_this_head() {
12086 let (_fixture, repo, work, _origin, fork) = denied_fixture("landed", true);
12087 let fork = fork.to_str().unwrap();
12088
12089 assert!(!repo.remote_branch_is_head(&work, fork, "issue-7"));
12090
12091 test_git(&work, &["push", "-q", fork, "main:refs/heads/issue-7"]);
12092 assert!(
12093 !repo.remote_branch_is_head(&work, fork, "issue-7"),
12094 "a branch somewhere else is not this push"
12095 );
12096
12097 test_git(&work, &["push", "-qf", fork, "HEAD:refs/heads/issue-7"]);
12098 assert!(repo.remote_branch_is_head(&work, fork, "issue-7"));
12099 }
12100
12101 #[test]
12102 fn a_fork_is_reached_by_whatever_reaches_origin() {
12103 let cases = [
12104 (
12105 "git@github.com:example/project.git",
12106 Some("git@github.com:someone/project.git"),
12107 ),
12108 (
12110 "github.com:example/project.git",
12111 Some("github.com:someone/project.git"),
12112 ),
12113 (
12115 "git@github-work:example/project.git",
12116 Some("git@github-work:someone/project.git"),
12117 ),
12118 (
12120 "ssh://git@ssh.github.com:443/example/project.git",
12121 Some("ssh://git@ssh.github.com:443/someone/project.git"),
12122 ),
12123 (
12124 "https://github.com/example/project",
12125 Some("https://github.com/someone/project"),
12126 ),
12127 (
12128 "https://ghe.example.com/scm/example/project.git/",
12129 Some("https://ghe.example.com/scm/someone/project.git"),
12130 ),
12131 (
12132 "git@host:/srv/example/project.git",
12133 Some("git@host:/srv/someone/project.git"),
12134 ),
12135 ("/srv/git/project.git", None),
12136 ("https://github.com/project", None),
12137 ("", None),
12138 ];
12139 for (origin, want) in cases {
12140 assert_eq!(
12141 want.map(str::to_string),
12142 fork_url(origin, "someone/project"),
12143 "{origin}"
12144 );
12145 }
12146 }
12147
12148 #[test]
12149 fn a_fork_found_or_made_is_reached_the_way_origin_is() {
12150 let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
12151 "ssh_url":"git@github.com:someone/project.git",
12152 "clone_url":"https://github.com/someone/project.git","fork":true}"#;
12153 let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
12154 "sshUrl":"git@github.com:someone/project.git",
12155 "url":"https://github.com/someone/project"}]"#;
12156
12157 let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
12158 assert_eq!("git@github-work:someone/project.git", fork.url);
12159 assert_eq!("someone", fork.owner);
12160 assert_eq!("someone/project", fork.full_name);
12161 let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
12162 .unwrap()
12163 .unwrap();
12164 assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
12165 assert_eq!(
12166 None,
12167 fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
12168 );
12169
12170 let fork = fork_from_api(made, "https://github.com/project").unwrap();
12173 assert_eq!("https://github.com/someone/project.git", fork.url);
12174 let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
12175 assert_eq!("git@github.com:someone/project.git", fork.url);
12176
12177 assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
12178 assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
12179 }
12180
12181 #[test]
12182 fn only_the_viewers_own_fork_can_be_pushed_to() {
12183 let pr = |owner: &str, name: &str| PrView {
12184 number: 9,
12185 url: String::new(),
12186 title: String::new(),
12187 body: String::new(),
12188 head_ref_name: "issue-7".into(),
12189 base_ref_name: "main".into(),
12190 state: "OPEN".into(),
12191 closing_issues_references: Vec::new(),
12192 is_cross_repository: true,
12193 head_repository_owner: Some(crate::model::Author {
12194 login: owner.into(),
12195 }),
12196 head_repository: Some(crate::model::HeadRepository { name: name.into() }),
12197 };
12198 let origin = "git@github.com:example/project.git";
12199
12200 assert_eq!(
12201 Some(PushTarget::Fork(Fork {
12202 owner: "Someone".into(),
12203 full_name: "Someone/project".into(),
12204 url: "git@github.com:Someone/project.git".into(),
12205 })),
12206 own_fork_target(&pr("Someone", "project"), "someone", origin)
12207 );
12208 assert_eq!(
12209 None,
12210 own_fork_target(&pr("other", "project"), "someone", origin)
12211 );
12212 assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
12213 assert_eq!(
12214 None,
12215 own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
12216 );
12217 let mut unnamed = pr("someone", "project");
12218 unnamed.head_repository_owner = None;
12219 assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
12220 }
12221
12222 #[cfg(unix)]
12225 #[test]
12226 fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
12227 let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
12228 test_git(
12229 &work,
12230 &[
12231 "push",
12232 "-q",
12233 fork.to_str().unwrap(),
12234 "issue-7:refs/heads/issue-7",
12235 ],
12236 );
12237 let published = test_git(&work, &["rev-parse", "issue-7"]);
12238 test_git(&work, &["checkout", "-q", "main"]);
12239 let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
12240 let pr = PrView {
12241 number: 31,
12242 url: String::new(),
12243 title: String::new(),
12244 body: String::new(),
12245 head_ref_name: "issue-7".into(),
12246 base_ref_name: "main".into(),
12247 state: "OPEN".into(),
12248 closing_issues_references: Vec::new(),
12249 is_cross_repository: true,
12250 head_repository_owner: None,
12251 head_repository: None,
12252 };
12253
12254 let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
12255
12256 assert_eq!("issue-7", branch);
12257 assert_eq!(
12258 published.trim(),
12259 test_git(&path, &["rev-parse", "HEAD"]).trim()
12260 );
12261 std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
12262 test_git(&path, &["commit", "-qam", "Answer a comment"]);
12263 repo.push_to(&path, &target, &branch, Some(published.trim()))
12264 .unwrap();
12265 assert_eq!(
12266 test_git(&path, &["rev-parse", "HEAD"]).trim(),
12267 test_git(&fork, &["rev-parse", "issue-7"]).trim()
12268 );
12269 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
12270 .trim()
12271 .is_empty());
12272 }
12273
12274 #[cfg(unix)]
12275 #[test]
12276 fn with_forking_off_a_refused_push_says_why_and_how() {
12277 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
12278
12279 let err = repo
12280 .publish_new_branch(&work, "issue-7", "main")
12281 .unwrap_err()
12282 .to_string();
12283
12284 assert!(
12285 err.contains("Permission to example/project.git denied to someone."),
12286 "{err}"
12287 );
12288 assert!(err.contains("fork_when_denied = true"), "{err}");
12289 assert!(test_git(&fork, &["branch", "--list", "issue-7"])
12290 .trim()
12291 .is_empty());
12292 assert_eq!(
12293 WriteSummary {
12294 attempted: 1,
12295 failed: 1
12296 },
12297 repo.write_summary()
12298 );
12299 }
12300}
12301
12302#[cfg(test)]
12303mod comment_page_tests {
12304 use super::*;
12305
12306 #[test]
12307 fn a_single_merged_array_is_read() {
12308 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
12309 assert_eq!(2, pages.len());
12310 assert_eq!(Some(2), pages[1]["id"].as_i64());
12311 }
12312
12313 #[test]
12314 fn concatenated_pages_from_an_older_gh_are_read_too() {
12315 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
12316 assert_eq!(2, pages.len());
12317 }
12318
12319 #[test]
12323 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
12324 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
12325 let pages = parse_comment_pages(text);
12326 assert_eq!(2, pages.len(), "{pages:?}");
12327 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
12328 }
12329
12330 #[test]
12331 fn empty_output_is_no_comments_not_a_panic() {
12332 assert!(parse_comment_pages("").is_empty());
12333 assert!(parse_comment_pages(" ").is_empty());
12334 assert!(parse_comment_pages("[]").is_empty());
12335 }
12336
12337 #[test]
12338 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
12339 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
12340 }
12341
12342 #[test]
12343 fn a_write_postcheck_rejects_truncated_comment_pages() {
12344 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
12345 assert!(
12346 error.to_string().contains("unexpected comment pages"),
12347 "{error}"
12348 );
12349 }
12350
12351 #[test]
12352 fn a_write_postcheck_rejects_empty_or_non_array_output() {
12353 assert!(try_parse_comment_pages("").is_err());
12354 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
12355 assert!(try_parse_comment_pages("[]").is_ok());
12356 }
12357
12358 #[test]
12359 fn state_is_found_in_the_last_matching_comment() {
12360 let payload = |round: u32| {
12361 format!(
12362 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
12363 )
12364 };
12365 let text = serde_json::to_string(&serde_json::json!([
12366 {"id": 1, "body": payload(1)},
12367 {"id": 2, "body": "looks good to me"},
12368 {"id": 3, "body": payload(5)},
12369 ]))
12370 .unwrap();
12371 let pages = parse_comment_pages(&text);
12372 let last = pages
12373 .iter()
12374 .rev()
12375 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
12376 .unwrap();
12377 assert_eq!(5, last.round);
12378 }
12379}
12380
12381#[cfg(test)]
12382mod linked_pr_tests {
12383 use super::*;
12384
12385 const REAL_PAYLOAD: &str = r#"[
12390 {"number":14252,"title":"fix: reject leading-dash branch names",
12391 "url":"https://github.com/cli/cli/pull/14252",
12392 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
12393 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
12394 "url":"https://github.com/cli/cli/issues/14238"}]},
12395 {"number":14217,"title":"another change",
12396 "url":"https://github.com/cli/cli/pull/14217",
12397 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
12398 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
12399 "url":"https://github.com/cli/cli/issues/9761"}]},
12400 {"number":14200,"title":"unlinked work",
12401 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
12402 ]"#;
12403
12404 #[test]
12405 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
12406 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
12407 assert_eq!(14252, pr.number);
12408 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
12409 }
12410
12411 #[test]
12412 fn the_right_pr_is_picked_out_of_several() {
12413 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
12414 }
12415
12416 #[test]
12417 fn an_issue_nobody_is_working_on_finds_nothing() {
12418 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
12419 }
12420
12421 #[test]
12422 fn an_unlinked_pr_is_never_matched() {
12423 for issue in [14200, 0, 1] {
12425 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
12426 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
12427 }
12428 }
12429 }
12430
12431 #[test]
12432 fn empty_or_broken_output_is_none_rather_than_a_panic() {
12433 assert!(find_linked_pr("", 1).is_none());
12434 assert!(find_linked_pr("[]", 1).is_none());
12435 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
12436 assert!(find_linked_pr("[{\"number\":", 1).is_none());
12437 }
12438
12439 #[test]
12446 fn an_unreadable_list_is_not_an_empty_list() {
12447 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
12448 assert!(linked_pr_rows("[{\"number\":").is_none());
12449 assert!(linked_pr_rows("").is_none());
12450
12451 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
12452 assert!(empty.is_empty());
12453 assert!(pick_linked_pr(&empty, 14238).is_none());
12454
12455 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
12456 assert_eq!(3, listed.len());
12457 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
12458 }
12459
12460 #[test]
12462 fn pr_view_reads_the_cross_repository_flag() {
12463 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
12464 "baseRefName":"main","state":"OPEN",
12465 "closingIssuesReferences":[],"isCrossRepository":true}"#;
12466 let pr: PrView = serde_json::from_str(json).unwrap();
12467 assert!(pr.is_cross_repository);
12468 assert!(pr.is_open());
12469
12470 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
12471 assert!(
12472 !serde_json::from_str::<PrView>(&same_repo)
12473 .unwrap()
12474 .is_cross_repository
12475 );
12476 }
12477}
12478
12479#[cfg(test)]
12480mod min_number_tests {
12481 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
12487 let mut numbers: Vec<i64> = open.to_vec();
12488 numbers.sort_unstable();
12489 if min_number > 0 {
12490 numbers.retain(|n| *n >= min_number);
12491 }
12492 numbers.truncate(limit);
12493 numbers
12494 }
12495
12496 #[test]
12497 fn the_floor_is_applied_before_the_cap_not_after() {
12498 let open = [12, 13, 14, 480, 481, 482];
12499 assert_eq!(vec![480, 481], pick(&open, 2, 480));
12500 assert!(!pick(&open, 2, 480).is_empty());
12503 }
12504
12505 #[test]
12506 fn no_floor_keeps_the_old_behaviour() {
12507 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
12508 }
12509
12510 #[test]
12511 fn the_floor_is_inclusive() {
12512 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
12513 }
12514
12515 #[test]
12516 fn a_floor_above_everything_open_yields_nothing() {
12517 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
12518 }
12519}
12520
12521#[cfg(test)]
12522mod review_record_tests {
12523 use super::*;
12524 use serde_json::json;
12525
12526 #[test]
12527 fn prune_state_knows_which_files_belong_to_a_pull_request() {
12528 assert_eq!(Some(12), state_file_number("pr-12.json"));
12529 assert_eq!(Some(12), state_file_number("review-12.json"));
12530 assert_eq!(Some(12), state_file_number("checkin-12.json"));
12531 for shared in [
12532 "spend.json",
12533 "implementors.json",
12534 "contested.json",
12535 "listen.json",
12536 "pr-.json",
12537 "pr-12.json.tmp",
12538 "pr-x.json",
12539 ] {
12540 assert_eq!(None, state_file_number(shared), "{shared}");
12541 }
12542 }
12543
12544 #[test]
12545 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
12546 let reviews = vec![
12547 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
12548 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
12549 ];
12550 assert!(has_viewer_review(&reviews, "me", &["the body"], "aaa"));
12551 assert!(!has_viewer_review(&reviews, "me", &["other"], "aaa"));
12552 assert!(!has_viewer_review(&reviews, "alice", &["the body"], "aaa"));
12553 }
12554
12555 #[test]
12559 fn a_review_that_landed_with_either_body_is_not_sent_again() {
12560 let brief = vec![
12561 json!({"user": {"login": "me"}, "body": "brief", "commit_id": "aaa", "state": "COMMENTED"}),
12562 ];
12563 let full = vec![
12564 json!({"user": {"login": "me"}, "body": "full", "commit_id": "aaa", "state": "CHANGES_REQUESTED"}),
12565 ];
12566 for landed in [&brief, &full] {
12567 assert!(has_viewer_review(landed, "me", &["brief", "full"], "aaa"));
12568 assert!(has_viewer_review(landed, "me", &["full", "brief"], "aaa"));
12569 assert!(!has_viewer_review(landed, "me", &["brief", "full"], "bbb"));
12570 }
12571 assert!(!has_viewer_review(&brief, "me", &["full"], "aaa"));
12572 assert!(!has_viewer_review(&full, "me", &[], "aaa"));
12573 }
12574
12575 #[test]
12579 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
12580 let older = vec![
12581 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
12582 ];
12583 assert!(!has_viewer_review(&older, "me", &["the body"], "bbb"));
12584 let dismissed = vec![
12585 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
12586 ];
12587 assert!(!has_viewer_review(&dismissed, "me", &["the body"], "bbb"));
12588 }
12589
12590 #[test]
12591 fn a_response_splits_into_status_headers_and_body() {
12592 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";
12593 let resp = parse_api_response(text).expect("a response");
12594 assert_eq!(200, resp.status);
12595 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
12596 assert_eq!(
12597 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
12598 resp.headers.get("last-modified")
12599 );
12600 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
12601
12602 let not_modified =
12603 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
12604 assert_eq!(304, not_modified.status);
12605 assert!(not_modified.body.is_empty());
12606
12607 assert_eq!(None, parse_api_response(""));
12608 assert_eq!(None, parse_api_response("gh: command not found\n"));
12609 }
12610
12611 #[test]
12612 fn review_requests_are_read_off_the_timeline_with_who_and_when() {
12613 let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
12614 {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
12615 {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
12616 {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
12617 {}
12618 ]}}}}})
12619 .to_string();
12620 let made = parse_review_requests(&text).expect("requests");
12621 assert_eq!(2, made.len());
12622 assert_eq!("2026-10-20T10:00:00Z", made[0].at);
12623 assert_eq!("viewer", made[0].who.login);
12624 assert!(made[0].who.slug.is_empty());
12625 assert_eq!("2026-10-20T11:00:00Z", made[1].at);
12626 assert!(made[1].who.login.is_empty());
12627 assert_eq!("example/ours", made[1].who.slug);
12628
12629 let none =
12630 json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
12631 assert_eq!(
12632 Some(0),
12633 parse_review_requests(&none.to_string()).map(|m| m.len())
12634 );
12635 assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
12636 assert!(parse_review_requests("gh: not json").is_none());
12637 }
12638
12639 #[test]
12640 fn assigned_issues_are_read_with_who_holds_them_and_when_each_was_assigned() {
12641 let text = json!({"data": {"repository": {"issues": {"nodes": [
12642 {
12643 "number": 1144,
12644 "title": "Send max",
12645 "assignees": {"nodes": [{"login": "viewer"}]},
12646 "timelineItems": {"nodes": [
12647 {"createdAt": "2026-08-10T10:00:00Z", "assignee": {"login": "viewer"}},
12648 {"createdAt": "2026-11-03T12:36:22Z", "assignee": {"login": "a-bot"}},
12649 {"createdAt": "2026-11-03T12:40:00Z", "assignee": null},
12650 {"assignee": {"login": "viewer"}},
12651 {}
12652 ]}
12653 },
12654 {"title": "no number"},
12655 {"number": 7}
12656 ]}}}})
12657 .to_string();
12658 let issues = parse_assigned_issues(&text).expect("issues");
12659 assert_eq!(2, issues.len());
12660 assert_eq!(1144, issues[0].number);
12661 assert_eq!("Send max", issues[0].title);
12662 assert_eq!(vec!["viewer".to_string()], issues[0].assignees);
12663 assert_eq!(
12664 vec![
12665 ("2026-08-10T10:00:00Z".to_string(), "viewer".to_string()),
12666 ("2026-11-03T12:36:22Z".to_string(), "a-bot".to_string()),
12667 ],
12668 issues[0].assigned
12669 );
12670 assert_eq!(7, issues[1].number);
12671 assert!(issues[1].assignees.is_empty() && issues[1].assigned.is_empty());
12672
12673 let none = json!({"data": {"repository": {"issues": {"nodes": []}}}});
12674 assert_eq!(
12675 Some(0),
12676 parse_assigned_issues(&none.to_string()).map(|i| i.len())
12677 );
12678 assert!(parse_assigned_issues(r#"{"errors": [{"message": "no"}]}"#).is_none());
12679 assert!(parse_assigned_issues("gh: not json").is_none());
12680 }
12681
12682 #[test]
12683 fn one_issues_assignments_are_read_the_same_way() {
12684 let text = json!({"data": {"repository": {"issue": {
12685 "number": 42,
12686 "title": "t",
12687 "assignees": {"nodes": [{"login": "Viewer"}]},
12688 "timelineItems": {"nodes": [
12689 {"createdAt": "2026-11-03T12:36:22Z", "assignee": {"login": "Viewer"}}
12690 ]}
12691 }}}})
12692 .to_string();
12693 let issue = parse_issue_assignments(&text).expect("an issue");
12694 assert_eq!(42, issue.number);
12695 assert_eq!(
12696 Some("2026-11-03T12:36:22Z".to_string()),
12697 issue.newest_for("viewer")
12698 );
12699 assert!(parse_issue_assignments(r#"{"data": {"repository": {"issue": null}}}"#).is_none());
12700 assert!(parse_issue_assignments("gh: not json").is_none());
12701 }
12702
12703 #[test]
12704 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
12705 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
12706 let error = crate::error::SparError::call_failed("gh: boom");
12707 assert!(
12708 reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(landed)).is_ok()
12709 );
12710
12711 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
12712 let err = reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(missing))
12713 .unwrap_err();
12714 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
12715
12716 let unreadable = Err(crate::error::SparError::new("no network"));
12717 let err =
12718 reconcile_review_post(5, &["the body"], "me", "aaa", error, unreadable).unwrap_err();
12719 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
12720 assert!(err.to_string().contains("could not be verified"), "{err}");
12721 }
12722
12723 #[test]
12724 fn a_review_found_standing_links_to_itself() {
12725 let url = "https://github.com/o/r/pull/5#pullrequestreview-7";
12726 let reviews = vec![
12727 json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa", "html_url": "https://github.com/o/r/pull/5#pullrequestreview-6"}),
12728 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "html_url": url}),
12729 ];
12730 let found = viewer_review(&reviews, "me", &["the body"], "aaa").map(html_url);
12731 assert_eq!(Some(Some(url.to_string())), found);
12732 let error = crate::error::SparError::call_failed("gh: boom");
12733 let reconciled =
12734 reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(reviews));
12735 assert_eq!(Some(url.to_string()), reconciled.unwrap());
12736
12737 let unlinked =
12738 vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
12739 let reconciled = reconcile_review_post(5, &["the body"], "me", "aaa", error, Ok(unlinked));
12740 assert_eq!(None, reconciled.unwrap(), "no address is still a review");
12741 }
12742
12743 #[test]
12744 fn a_comment_found_standing_links_to_itself() {
12745 let url = "https://github.com/o/r/pull/34#issuecomment-9";
12746 let comments = vec![
12747 json!({"body": "other", "html_url": "https://github.com/o/r/pull/34#issuecomment-8"}),
12748 json!({"body": "the summary", "html_url": url}),
12749 ];
12750 assert_eq!(
12751 Some(url.to_string()),
12752 exact_comment(&comments, "the summary").and_then(html_url)
12753 );
12754 let reconciled = reconcile_comment_post(
12755 34,
12756 "the summary",
12757 crate::error::SparError::new("connection lost"),
12758 Ok(comments),
12759 );
12760 assert_eq!(Some(url.to_string()), reconciled.unwrap());
12761 assert_eq!(None, html_url(&json!({"body": "x", "html_url": ""})));
12762 }
12763
12764 #[test]
12765 fn the_comment_gh_made_is_the_address_it_printed() {
12766 let url = "https://github.com/o/r/pull/34#issuecomment-9";
12767 assert_eq!(Some(url.to_string()), printed_url(&format!("{url}\n")));
12768 assert_eq!(
12769 Some(url.to_string()),
12770 printed_url(&format!("Warning: 1 uncommitted change\n {url} \ndone\n"))
12771 );
12772 assert_eq!(None, printed_url(""));
12773 assert_eq!(None, printed_url("commented\nsee http://example.test\n"));
12774 }
12775
12776 #[test]
12777 fn the_review_github_made_is_the_address_in_its_answer() {
12778 let url = "https://github.com/o/r/pull/5#pullrequestreview-7";
12779 let answer = json!({"id": 7, "state": "APPROVED", "html_url": url}).to_string();
12780 assert_eq!(Some(url.to_string()), review_url(&answer));
12781 assert_eq!(None, review_url(r#"{"id": 7, "state": "APPROVED"}"#));
12782 assert_eq!(None, review_url(""));
12783 assert_eq!(None, review_url("not json"));
12784 }
12785
12786 #[test]
12787 fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
12788 let comments = vec![
12789 InlineComment {
12790 path: "src/a.rs".into(),
12791 line: 12,
12792 start_line: None,
12793 body: "one".into(),
12794 },
12795 InlineComment {
12796 path: "src/b.rs".into(),
12797 line: 9,
12798 start_line: Some(4),
12799 body: "two".into(),
12800 },
12801 ];
12802 assert_eq!(
12803 json!({
12804 "commit_id": "abc",
12805 "event": "COMMENT",
12806 "body": "the body",
12807 "comments": [
12808 {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
12809 {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
12810 "start_side": "RIGHT", "body": "two"},
12811 ],
12812 }),
12813 review_payload("abc", "COMMENT", "the body", &comments)
12814 );
12815 }
12816
12817 #[test]
12818 fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
12819 let diff = r#"diff --git a/src/a.rs b/src/a.rs
12820index 1111111..2222222 100644
12821--- a/src/a.rs
12822+++ b/src/a.rs
12823@@ -1,3 +1,4 @@ fn x
12824 one
12825+++ an added line that looks like a header
12826 two
12827 three
12828@@ -20 +21,2 @@
12829-old
12830+new
12831+more
12832@@ -30,1 +32,0 @@
12833-gone
12834diff --git a/gone.rs b/gone.rs
12835deleted file mode 100644
12836--- a/gone.rs
12837+++ /dev/null
12838@@ -1,2 +0,0 @@
12839-a
12840-b
12841diff --git a/old.rs b/renamed.rs
12842similarity index 90%
12843rename from old.rs
12844rename to renamed.rs
12845--- a/old.rs
12846+++ b/renamed.rs
12847@@ -5,0 +6,1 @@
12848+inserted
12849diff --git a/with space.rs b/with space.rs
12850--- a/with space.rs
12851+++ b/with space.rs
12852@@ -2 +2 @@
12853-x
12854+y
12855diff --git "a/sp\tace.rs" "b/sp\tace.rs"
12856--- "a/sp\tace.rs"
12857+++ "b/sp\tace.rs"
12858@@ -1 +1 @@
12859-x
12860+y
12861"#;
12862 let hunks = new_side_hunks(diff);
12863 assert_eq!(
12864 Hunks::from([
12865 ("renamed.rs".to_string(), vec![(6, 6)]),
12866 ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
12867 ("with space.rs".to_string(), vec![(2, 2)]),
12868 ]),
12869 hunks
12870 );
12871 }
12872
12873 #[test]
12874 fn only_a_refusal_is_final_enough_to_send_a_review_again() {
12875 let error = |text: &str| crate::error::SparError::new(text);
12876 assert!(refused(&error(
12877 "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
12878 )));
12879 assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
12880 assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
12881 assert!(!refused(&error("command timed out after 300s")));
12882 }
12883
12884 #[test]
12885 fn a_hunk_header_with_its_counts_left_out_counts_one() {
12886 assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
12887 assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
12888 assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
12889 assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
12890 }
12891}