1use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20 Contested, ContestedItem, Followup, Issue, IssueRef, ItemKind, LiveHead, PersistedState, PrRef,
21 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";
66
67pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
69pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) enum Disposable {
74 BuildOutput,
77 ReviewOutput,
85}
86
87pub(crate) fn is_spar_marker(path: &Path) -> bool {
95 let mut components = path.components();
96 let Some(std::path::Component::Normal(name)) = components.next() else {
97 return false;
98 };
99 if components.next().is_some() {
100 return false;
101 }
102 let Some(name) = name.to_str() else {
103 return false;
104 };
105 name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
106}
107
108pub(crate) fn is_spar_own_path(path: &Path) -> bool {
121 if is_spar_marker(path) {
122 return true;
123 }
124 let Some(std::path::Component::Normal(name)) = path.components().next() else {
125 return false;
126 };
127 name == OsStr::new(STATE_DIR)
128}
129
130const SPLIT_SLOTS: u32 = 20;
135
136#[derive(Debug, Clone)]
137pub struct SplitPushError {
138 message: String,
139 retain_worktree: bool,
140}
141
142impl SplitPushError {
143 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
144 Self {
145 message: message.into(),
146 retain_worktree,
147 }
148 }
149
150 pub fn retain_worktree(&self) -> bool {
151 self.retain_worktree
152 }
153}
154
155impl std::fmt::Display for SplitPushError {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.write_str(&self.message)
158 }
159}
160
161impl std::error::Error for SplitPushError {}
162
163fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
168 match attempt {
169 1 => format!("split-{parent}-{index}"),
170 n => format!("split-{parent}-{index}-{n}"),
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum PushTarget {
177 Origin,
178 Fork(Fork),
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Fork {
186 pub owner: String,
189 pub full_name: String,
191 pub url: String,
194}
195
196impl PushTarget {
197 pub fn head(&self, branch: &str) -> String {
199 match self {
200 PushTarget::Origin => branch.to_string(),
201 PushTarget::Fork(fork) => format!("{}:{branch}", fork.owner),
202 }
203 }
204
205 fn remote(&self) -> &str {
206 match self {
207 PushTarget::Origin => "origin",
208 PushTarget::Fork(fork) => &fork.url,
209 }
210 }
211
212 fn describe(&self, branch: &str) -> String {
213 match self {
214 PushTarget::Origin => format!("origin/{branch}"),
215 PushTarget::Fork(fork) => format!("{branch} on the fork {}", fork.full_name),
216 }
217 }
218}
219
220#[derive(Debug)]
221pub struct Repo {
222 root: PathBuf,
223 pub style: Style,
224 pub branch_prefix: String,
225 pub state_store: StateStore,
226 pub followups: Followups,
227 pub drafts: Drafts,
228 pub fork_when_denied: bool,
230 fork: OnceLock<Fork>,
233 viewer: OnceLock<String>,
239 checkpoints: Mutex<BTreeMap<i64, u64>>,
244 writes: WriteStats,
245}
246
247#[derive(Debug, Default)]
248struct WriteStats {
249 attempted: AtomicUsize,
250 failed: AtomicUsize,
251}
252
253#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
254pub(crate) struct WriteSummary {
255 pub(crate) attempted: usize,
256 pub(crate) failed: usize,
257}
258
259impl WriteSummary {
260 pub(crate) fn succeeded(self) -> usize {
261 self.attempted.saturating_sub(self.failed)
262 }
263}
264
265#[derive(Debug, Clone)]
271pub(crate) struct WorktreeBaseline {
272 attributes: AttributeState,
273 ignored_untracked: IgnoredState,
274 git_state: GitState,
275}
276
277#[derive(Debug, Clone)]
283pub(crate) struct WorktreeCheckpoint {
284 path: PathBuf,
285 attributes: AttributeState,
286 git_state: GitState,
287 ignored_untracked: IgnoredState,
288}
289
290#[derive(Debug, Clone, Default, PartialEq, Eq)]
291pub(crate) struct AttributeState {
292 files: BTreeMap<PathBuf, [u8; 32]>,
293}
294
295#[derive(Debug, Clone, Default, PartialEq, Eq)]
300pub(crate) struct IgnoredState {
301 files: BTreeMap<PathBuf, UntrackedFile>,
302 ignored: BTreeSet<PathBuf>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
312struct UntrackedFile {
313 kind: u8,
314 len: u64,
315 modified: Option<std::time::SystemTime>,
316 created: Option<std::time::SystemTime>,
317 readonly: bool,
318 symlink_target: Option<Vec<u8>>,
319 #[cfg(unix)]
320 device: u64,
321 #[cfg(unix)]
322 inode: u64,
323 #[cfg(unix)]
324 mode: u32,
325 #[cfg(unix)]
326 change_seconds: i64,
327 #[cfg(unix)]
328 change_nanoseconds: i64,
329}
330
331#[derive(Debug, Clone, Default, PartialEq, Eq)]
332pub(crate) struct GitState {
333 repositories: BTreeMap<PathBuf, RepositoryState>,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq)]
337struct RepositoryState {
338 head: String,
339 unsafe_index_flags: Vec<u8>,
340 tracked: BTreeMap<PathBuf, TrackedEntry>,
341 gitlinks: BTreeMap<PathBuf, String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345struct TrackedEntry {
346 index_mode: String,
347 index_oid: String,
348 worktree: Option<WorktreeFile>,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
352struct WorktreeFile {
353 mode: String,
354 #[cfg(unix)]
355 permissions: u32,
356 raw_oid: String,
357 fingerprint: [u8; 32],
358 content: [u8; 32],
359}
360
361struct Gitlink {
362 path: PathBuf,
363 oid: String,
364}
365
366struct IndexEntry {
367 path: PathBuf,
368 mode: String,
369 oid: String,
370}
371
372impl IgnoredState {
373 fn is_ignored(&self, path: &Path) -> bool {
374 self.ignored.contains(path)
375 }
376
377 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
378 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
379 paths.extend(after.files.keys().cloned());
380 paths
381 .into_iter()
382 .filter(|path| {
383 self.files.get(path) != after.files.get(path)
384 || self.is_ignored(path) != after.is_ignored(path)
385 })
386 .collect()
387 }
388
389 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
390 self.files
391 .iter()
392 .filter(|(path, state)| after.files.get(*path) != Some(*state))
393 .map(|(path, _)| path.clone())
394 .collect()
395 }
396
397 fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
398 after
399 .files
400 .keys()
401 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
402 .cloned()
403 .collect()
404 }
405
406 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
416 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
417 paths.extend(after.files.keys());
418 paths.into_iter().any(|path| {
419 if self.files.get(path) == after.files.get(path)
420 && self.is_ignored(path) == after.is_ignored(path)
421 {
422 return false;
423 }
424 !self.generated_move(after, path)
425 })
426 }
427
428 pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
456 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
457 paths.extend(after.files.keys());
458 let mut leavings = ReadOnlyLeavings::default();
459 for path in paths {
460 if self.files.get(path) == after.files.get(path)
461 && self.is_ignored(path) == after.is_ignored(path)
462 {
463 continue;
464 }
465 if !self.ignored_file_move(after, path) {
466 leavings.condemning.push(path.clone());
467 } else if !is_generated_artifact(path) {
468 leavings.tolerated.push(path.clone());
469 }
470 }
471 leavings
472 }
473
474 fn generated_move(&self, after: &Self, path: &Path) -> bool {
488 is_generated_artifact(path) && self.disposable_move(after, path)
489 }
490
491 fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
495 if path.starts_with(WORKTREE_DIR)
496 && self.files.get(path).is_some_and(|file| file.kind == 3)
497 && self.files.get(path) != after.files.get(path)
498 {
499 return false;
500 }
501 self.disposable_move(after, path)
502 }
503
504 fn disposable_move(&self, after: &Self, path: &Path) -> bool {
507 self.disposable(path) && after.disposable(path)
508 }
509
510 fn disposable(&self, path: &Path) -> bool {
513 !self.files.contains_key(path) || self.is_ignored(path)
514 }
515}
516
517impl GitState {
518 pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
524 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
525 prefixes.extend(after.repositories.keys());
526 let mut notes: Vec<String> = Vec::new();
527 let mut paths: Vec<PathBuf> = Vec::new();
528 for prefix in prefixes {
529 let label = describe_repository(prefix);
530 match (
531 self.repositories.get(prefix),
532 after.repositories.get(prefix),
533 ) {
534 (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
535 (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
536 (Some(before), Some(current)) => {
537 if before.head != current.head {
538 notes.push(format!("HEAD moved in {label}"));
539 }
540 if before.unsafe_index_flags != current.unsafe_index_flags {
541 notes.push(format!("index flags changed in {label}"));
542 }
543 paths.extend(
544 differing_keys(&before.gitlinks, ¤t.gitlinks)
545 .chain(differing_keys(&before.tracked, ¤t.tracked))
546 .map(|path| prefix.join(path)),
547 );
548 }
549 (None, None) => {}
550 }
551 }
552 if !paths.is_empty() {
553 paths.sort();
554 paths.dedup();
555 notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
556 }
557 (!notes.is_empty()).then(|| notes.join("; "))
558 }
559
560 pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
563 let difference = self.describe_difference(after)?;
564 let Some(moved) = self.moved_wholesale(after) else {
565 return Some(difference);
566 };
567 if said_once("moved", cwd, &moved) {
570 logwarn!(
571 "the checkout at {} moved while a read-only call was reading it: {moved}. The \
572 answer was kept, and it describes the tree as it stood before the move.",
573 cwd.display()
574 );
575 }
576 None
577 }
578
579 fn moved_wholesale(&self, after: &Self) -> Option<String> {
596 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
597 prefixes.extend(after.repositories.keys());
598 let mut notes: Vec<String> = Vec::new();
599 for prefix in prefixes {
600 let before = self.repositories.get(prefix)?;
602 let current = after.repositories.get(prefix)?;
603 if before.unsafe_index_flags != current.unsafe_index_flags
604 || before.gitlinks != current.gitlinks
605 {
606 return None;
607 }
608 let moved: Vec<&PathBuf> = differing_keys(&before.tracked, ¤t.tracked).collect();
609 if before.head == current.head {
610 if moved.is_empty() {
611 continue;
612 }
613 return None;
614 }
615 if !moved.iter().all(|path| {
616 checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
617 }) {
618 return None;
619 }
620 let label = describe_repository(prefix);
621 notes.push(match moved.len() {
622 0 => format!("HEAD moved in {label}"),
623 count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
624 });
625 }
626 (!notes.is_empty()).then(|| notes.join("; "))
627 }
628}
629
630fn checked_out(entry: Option<&TrackedEntry>) -> bool {
637 entry.is_none_or(|entry| {
638 entry
639 .worktree
640 .as_ref()
641 .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
642 })
643}
644
645pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
656 if paths.is_empty() {
657 return;
658 }
659 let named = list_paths(paths);
660 if !said_once("ignored", cwd, &named) {
661 return;
662 }
663 logdim!(
664 "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
665 project ignores these paths.",
666 paths.len(),
667 cwd.display()
668 );
669}
670
671fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
678 static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
679 SAID.lock()
680 .unwrap_or_else(std::sync::PoisonError::into_inner)
681 .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
682}
683
684fn describe_repository(prefix: &Path) -> String {
687 if prefix.as_os_str().is_empty() {
688 "the worktree".to_string()
689 } else {
690 format!("the submodule at {:?}", prefix.as_os_str())
691 }
692}
693
694fn differing_keys<'a, V: PartialEq>(
696 before: &'a BTreeMap<PathBuf, V>,
697 after: &'a BTreeMap<PathBuf, V>,
698) -> impl Iterator<Item = &'a PathBuf> {
699 let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
700 keys.extend(after.keys());
701 keys.into_iter()
702 .filter(move |key| before.get(*key) != after.get(*key))
703}
704
705#[derive(Default)]
711pub(crate) struct ReadOnlyLeavings {
712 pub(crate) tolerated: Vec<PathBuf>,
716 pub(crate) condemning: Vec<PathBuf>,
718}
719
720pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
724 match (first, second) {
725 (None, None) => None,
726 (Some(one), None) | (None, Some(one)) => Some(one),
727 (Some(first), Some(second)) => Some(format!("{first}; {second}")),
728 }
729}
730
731pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
736 const SHOWN: usize = 5;
737 let mut listed = paths
738 .iter()
739 .take(SHOWN)
740 .map(|path| format!("{:?}", path.as_os_str()))
741 .collect::<Vec<_>>()
742 .join(", ");
743 if paths.len() > SHOWN {
744 listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
745 }
746 listed
747}
748
749#[derive(Default)]
756struct IgnoredArtifacts {
757 new_paths: BTreeSet<PathBuf>,
758 changed_paths: BTreeSet<PathBuf>,
759 removed_paths: BTreeSet<PathBuf>,
760}
761
762impl IgnoredArtifacts {
763 fn left(&mut self, allowed: AllowedArtifacts) {
764 self.new_paths.extend(allowed.present);
765 self.removed_paths.extend(allowed.removed);
766 }
767
768 fn changed(&mut self, allowed: AllowedArtifacts) {
769 self.changed_paths.extend(allowed.present);
770 self.removed_paths.extend(allowed.removed);
771 }
772
773 fn report(&self, cwd: &Path) {
775 let paths: BTreeSet<_> = self
776 .new_paths
777 .iter()
778 .chain(&self.changed_paths)
779 .chain(&self.removed_paths)
780 .cloned()
781 .collect();
782 if !paths.is_empty() {
783 logdim!(
784 "the editing call changed {} ignored file(s) in {}: {}. They are outside \
785 the commit; cleanup checks them separately.",
786 paths.len(),
787 cwd.display(),
788 list_paths(&paths.into_iter().collect::<Vec<_>>())
789 );
790 }
791 }
792}
793
794#[derive(Default)]
798struct AllowedArtifacts {
799 present: Vec<PathBuf>,
800 removed: Vec<PathBuf>,
801}
802
803impl AllowedArtifacts {
804 fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
805 let (present, removed) = paths
806 .into_iter()
807 .partition(|path| after.files.contains_key(path));
808 Self { present, removed }
809 }
810}
811
812fn is_generated_artifact(path: &Path) -> bool {
818 const DIRECTORIES: &[&str] = &[
819 "target",
820 "dist",
821 "node_modules",
822 "__pycache__",
823 ".pytest_cache",
824 ".mypy_cache",
825 ".ruff_cache",
826 ".tox",
827 ".nox",
828 ".venv",
829 "venv",
830 ".gradle",
831 ".build",
832 "DerivedData",
833 ".next",
834 ".nuxt",
835 ".svelte-kit",
836 ".turbo",
837 "coverage",
838 ];
839 path.components().any(|component| {
840 let std::path::Component::Normal(name) = component else {
841 return false;
842 };
843 DIRECTORIES
844 .iter()
845 .any(|directory| name == OsStr::new(directory))
846 })
847}
848
849fn is_desktop_metadata(path: &Path) -> bool {
855 path.file_name() == Some(OsStr::new(".DS_Store"))
856}
857
858fn merge_pr_args<'a>(
859 number: &'a str,
860 expected_head: Option<&'a str>,
861 delete_branch: bool,
862) -> Vec<&'a str> {
863 let mut args = vec!["pr", "merge", number, "--squash"];
864 if delete_branch {
865 args.push("--delete-branch");
866 }
867 if let Some(expected_head) = expected_head {
868 args.extend(["--match-head-commit", expected_head]);
869 }
870 args
871}
872
873fn push_denial(message: &str) -> Option<&str> {
883 message
884 .lines()
885 .map(|line| {
886 let line = line.trim();
887 let line = line.strip_prefix("ERROR:").unwrap_or(line);
888 line.strip_prefix("remote:").unwrap_or(line).trim()
889 })
890 .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
891}
892
893fn push_rejection(message: &str) -> Option<&str> {
896 message
897 .lines()
898 .map(|line| line.trim().trim_start_matches('!').trim())
899 .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
900}
901
902fn push_error(
903 target: &PushTarget,
904 branch: &str,
905 e: &crate::error::SparError,
906) -> crate::error::SparError {
907 let message = e.to_string();
908 if let Some(denied) = push_denial(&message) {
909 return spar_err!("could not push to {}. {denied}", target.describe(branch));
910 }
911 spar_err!(
912 "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
913 target.describe(branch),
914 push_rejection(&message).unwrap_or(e.last_line())
915 )
916}
917
918fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
927 let origin = origin_url.trim();
928 let path_start = match origin.find("://") {
929 Some(scheme) => {
930 let host = scheme + 3;
931 host + origin[host..].find('/')? + 1
932 }
933 None => {
934 let colon = origin.find(':')?;
937 if origin[..colon].contains('/') {
938 return None;
939 }
940 colon + 1
941 }
942 };
943 let path = origin[path_start..].trim_end_matches('/');
944 let (path, suffix) = match path.strip_suffix(".git") {
945 Some(bare) => (bare, ".git"),
946 None => (path, ""),
947 };
948 let segments: Vec<&str> = path.split('/').collect();
949 if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
950 return None;
951 }
952 let prefix = &segments[..segments.len() - 2];
953 let prefix = if prefix.is_empty() {
954 String::new()
955 } else {
956 format!("{}/", prefix.join("/"))
957 };
958 Some(format!(
959 "{}{prefix}{full_name}{suffix}",
960 &origin[..path_start]
961 ))
962}
963
964fn fork_reached_like_origin(
969 owner: String,
970 full_name: String,
971 origin_url: &str,
972 ssh_url: String,
973 https_url: String,
974) -> Fork {
975 let origin = origin_url.trim();
976 let url = fork_url(origin, &full_name).unwrap_or_else(|| {
977 if origin.starts_with("git@") || origin.starts_with("ssh://") {
978 ssh_url
979 } else {
980 https_url
981 }
982 });
983 Fork {
984 owner,
985 full_name,
986 url,
987 }
988}
989
990#[derive(Deserialize)]
991struct ForkOwner {
992 login: String,
993}
994
995fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
997 #[derive(Deserialize)]
998 struct Answer {
999 full_name: String,
1000 owner: ForkOwner,
1001 ssh_url: String,
1002 clone_url: String,
1003 }
1004
1005 let answer: Answer = serde_json::from_str(answer.trim())
1006 .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1007 Ok(fork_reached_like_origin(
1008 answer.owner.login,
1009 answer.full_name,
1010 origin_url,
1011 answer.ssh_url,
1012 answer.clone_url,
1013 ))
1014}
1015
1016fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1019 #[derive(Deserialize)]
1020 #[serde(rename_all = "camelCase")]
1021 struct Node {
1022 name_with_owner: String,
1023 owner: ForkOwner,
1024 ssh_url: String,
1025 url: String,
1026 }
1027
1028 let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1029 .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1030 Ok(nodes.into_iter().next().map(|node| {
1031 fork_reached_like_origin(
1032 node.owner.login,
1033 node.name_with_owner,
1034 origin_url,
1035 node.ssh_url,
1036 node.url,
1037 )
1038 }))
1039}
1040
1041fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1044 let owner = pr.head_repository_owner.as_ref()?.login.trim();
1045 let name = pr.head_repository.as_ref()?.name.trim();
1046 if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1047 return None;
1048 }
1049 let full_name = format!("{owner}/{name}");
1050 Some(PushTarget::Fork(Fork {
1051 owner: owner.to_string(),
1052 url: fork_url(origin_url, &full_name)?,
1053 full_name,
1054 }))
1055}
1056
1057fn reconcile_pr_creation(
1058 branch: &str,
1059 created: Result<String>,
1060 found: Result<Option<PrRef>>,
1061) -> Result<PrRef> {
1062 match (created, found) {
1063 (_, Ok(Some(pr))) => Ok(pr),
1064 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1065 "PR creation reported success but none was found for {branch}"
1066 ))),
1067 (Err(create), Ok(None)) => Err(spar_err!(
1068 "could not open a PR for {branch}. {}",
1069 create.last_line()
1070 )),
1071 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1072 "PR creation reported success for {branch}, but it could not be verified. {}",
1073 check.last_line()
1074 ))),
1075 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1076 "could not open a PR for {branch}. {} The result could not be verified: {}",
1077 create.last_line(),
1078 check.last_line()
1079 ))),
1080 }
1081}
1082
1083fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1084 #[derive(Deserialize)]
1085 #[serde(rename_all = "camelCase")]
1086 struct Row {
1087 number: i64,
1088 #[serde(default)]
1089 url: String,
1090 #[serde(default)]
1091 title: String,
1092 base_ref_name: String,
1093 }
1094
1095 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1096 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1097 })?;
1098 Ok(rows
1099 .into_iter()
1100 .find(|row| row.base_ref_name == base)
1101 .map(|row| PrRef {
1102 number: row.number,
1103 url: row.url,
1104 title: row.title,
1105 }))
1106}
1107
1108fn signed(body: &str) -> String {
1111 if body.contains(COMMENT_MARKER) {
1112 return body.to_string();
1113 }
1114 format!("{body}\n\n{COMMENT_MARKER}")
1115}
1116
1117pub fn is_spar_comment(body: &str) -> bool {
1119 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1120}
1121
1122#[derive(Debug, Clone, Default, PartialEq, Eq)]
1124pub struct ApiResponse {
1125 pub status: u16,
1126 pub headers: BTreeMap<String, String>,
1128 pub body: String,
1129}
1130
1131pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1135 let mut lines = stdout.split_inclusive('\n');
1136 let first = lines.next()?.trim_end_matches(['\r', '\n']);
1137 if !first.starts_with("HTTP/") {
1138 return None;
1139 }
1140 let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1141 let mut headers = BTreeMap::new();
1142 let mut body = String::new();
1143 let mut in_body = false;
1144 for line in lines {
1145 if in_body {
1146 body.push_str(line);
1147 continue;
1148 }
1149 let bare = line.trim_end_matches(['\r', '\n']);
1150 if bare.is_empty() {
1151 in_body = true;
1152 continue;
1153 }
1154 if let Some((name, value)) = bare.split_once(':') {
1155 headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1156 }
1157 }
1158 Some(ApiResponse {
1159 status,
1160 headers,
1161 body,
1162 })
1163}
1164
1165const REQUESTS_QUERY: &str = "\
1168query($owner: String!, $repo: String!, $number: Int!) {
1169 repository(owner: $owner, name: $repo) {
1170 pullRequest(number: $number) {
1171 timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1172 nodes {
1173 ... on ReviewRequestedEvent {
1174 createdAt
1175 requestedReviewer {
1176 ... on User { login }
1177 ... on Team { combinedSlug }
1178 }
1179 }
1180 }
1181 }
1182 }
1183 }
1184}";
1185
1186pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1190 let v: Value = serde_json::from_str(text).ok()?;
1191 let nodes = v
1192 .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1193 .as_array()?;
1194 let text_at = |node: &Value, path: &str| {
1195 node.pointer(path)
1196 .and_then(Value::as_str)
1197 .unwrap_or_default()
1198 .to_string()
1199 };
1200 Some(
1201 nodes
1202 .iter()
1203 .map(|node| crate::model::ReviewRequested {
1204 at: text_at(node, "/createdAt"),
1205 who: crate::model::Login {
1206 login: text_at(node, "/requestedReviewer/login"),
1207 slug: text_at(node, "/requestedReviewer/combinedSlug"),
1208 },
1209 })
1210 .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1211 .collect(),
1212 )
1213}
1214
1215pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1219 let stem = name.strip_suffix(".json")?;
1220 ["pr-", "review-", "checkin-"]
1221 .iter()
1222 .find_map(|prefix| stem.strip_prefix(prefix))
1223 .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1224 .and_then(|digits| digits.parse().ok())
1225}
1226
1227fn has_viewer_review(reviews: &[Value], viewer: &str, body: &str, commit: &str) -> bool {
1232 reviews.iter().any(|review| {
1233 let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1234 let standing = !matches!(
1235 field("state").to_uppercase().as_str(),
1236 "DISMISSED" | "PENDING"
1237 );
1238 review
1239 .get("user")
1240 .and_then(|u| u.get("login"))
1241 .and_then(Value::as_str)
1242 .is_some_and(|login| crate::comments::same_login(login, viewer))
1243 && field("body") == body
1244 && field("commit_id").eq_ignore_ascii_case(commit)
1245 && standing
1246 })
1247}
1248
1249fn reconcile_review_post(
1250 number: i64,
1251 body: &str,
1252 viewer: &str,
1253 commit: &str,
1254 post_error: crate::error::SparError,
1255 reviews: Result<Vec<Value>>,
1256) -> Result<()> {
1257 match reviews {
1258 Ok(reviews) if has_viewer_review(&reviews, viewer, body, commit) => Ok(()),
1259 Ok(_) => Err(post_error),
1260 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1261 "could not submit a review on #{number}. {} The result could not be verified: {}",
1262 post_error.last_line(),
1263 read_error.last_line()
1264 ))),
1265 }
1266}
1267
1268fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1269 comments.iter().any(|comment| {
1270 comment
1271 .get("body")
1272 .and_then(Value::as_str)
1273 .is_some_and(|seen| seen == body)
1274 })
1275}
1276
1277fn reconcile_comment_post(
1278 number: i64,
1279 body: &str,
1280 post_error: crate::error::SparError,
1281 comments: Result<Vec<Value>>,
1282) -> Result<()> {
1283 match comments {
1284 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1285 Ok(_) => Err(post_error),
1286 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1287 "could not comment on #{number}. {} The result could not be verified: {}",
1288 post_error.last_line(),
1289 read_error.last_line()
1290 ))),
1291 }
1292}
1293
1294fn reconcile_issue_edit(
1295 number: i64,
1296 wanted: &str,
1297 edit_error: crate::error::SparError,
1298 observed: Result<String>,
1299) -> Result<()> {
1300 match observed {
1301 Ok(body) if body == wanted => Ok(()),
1302 Ok(_) => Err(spar_err!(
1303 "could not rewrite the body of #{number}. {}",
1304 edit_error.last_line()
1305 )),
1306 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1307 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1308 edit_error.last_line(),
1309 read_error.last_line()
1310 ))),
1311 }
1312}
1313
1314fn issue_url_has_number(url: &str) -> bool {
1315 url.trim()
1316 .rsplit('/')
1317 .next()
1318 .and_then(|tail| tail.parse::<i64>().ok())
1319 .is_some_and(|number| number > 0)
1320}
1321
1322fn reconcile_issue_creation(
1323 title: &str,
1324 created: Result<String>,
1325 found: Result<Option<ExistingIssue>>,
1326) -> Result<String> {
1327 match (created, found) {
1328 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1329 (_, Ok(Some(issue))) => Ok(issue.url),
1330 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1331 "issue creation reported success but no matching issue was found for {title:?}"
1332 ))),
1333 (Err(create), Ok(None)) => Err(spar_err!(
1334 "could not file issue {title:?}. {}",
1335 create.last_line()
1336 )),
1337 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1338 "issue creation reported success for {title:?}, but it could not be verified. {}",
1339 check.last_line()
1340 ))),
1341 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1342 "could not file issue {title:?}. {} The result could not be verified: {}",
1343 create.last_line(),
1344 check.last_line()
1345 ))),
1346 }
1347}
1348
1349fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1350 if output.trim().is_empty() {
1351 return Ok(None);
1352 }
1353 for line in output.lines() {
1354 let mut fields = line.split_whitespace();
1355 let oid = fields.next().unwrap_or_default();
1356 let name = fields.next().unwrap_or_default();
1357 if name == remote_ref && !oid.is_empty() {
1358 return Ok(Some(oid.to_string()));
1359 }
1360 }
1361 Err(spar_err!(
1362 "origin returned an unexpected ref listing for {remote_ref}"
1363 ))
1364}
1365
1366fn reconcile_failed_split_push(
1367 branch: &str,
1368 push_error: crate::error::SparError,
1369 local: Result<String>,
1370 remote: Result<String>,
1371) -> std::result::Result<(), SplitPushError> {
1372 let remote_ref = format!("refs/heads/{branch}");
1373 match (local, remote) {
1374 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1375 Ok(Some(oid)) if oid == local.trim() => Ok(()),
1376 Ok(_) => Err(SplitPushError::new(
1377 format!(
1378 "could not create origin/{branch}. {} The remote branch is absent or points \
1379 somewhere else. Nothing was overwritten.",
1380 push_error.last_line()
1381 ),
1382 false,
1383 )),
1384 Err(check) => Err(SplitPushError::new(
1385 format!(
1386 "could not confirm whether origin/{branch} was created. {} The remote result \
1387 could not be verified: {}",
1388 push_error.last_line(),
1389 check.last_line()
1390 ),
1391 true,
1392 )),
1393 },
1394 (local, remote) => {
1395 let check = match (local, remote) {
1396 (Err(local), Err(remote)) => format!(
1397 "the local commit could not be read: {}; origin could not be read: {}",
1398 local.last_line(),
1399 remote.last_line()
1400 ),
1401 (Err(local), _) => {
1402 format!("the local commit could not be read: {}", local.last_line())
1403 }
1404 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1405 _ => unreachable!(),
1406 };
1407 Err(SplitPushError::new(
1408 format!(
1409 "could not confirm whether origin/{branch} was created. {} The result could \
1410 not be verified because {check}",
1411 push_error.last_line()
1412 ),
1413 true,
1414 ))
1415 }
1416 }
1417}
1418
1419impl Repo {
1420 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1421 let root =
1422 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1423 let inside = proc::run_str(
1426 &["git", "rev-parse", "--is-inside-work-tree"],
1427 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1428 )
1429 .unwrap_or_default();
1430 if inside.trim() != "true" {
1431 bail!("not a git repository: {}", root.display());
1432 }
1433 let repo = Self {
1434 root,
1435 style: cfg.style.clone(),
1436 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1437 state_store: cfg.loop_cfg.state_store,
1438 followups: cfg.loop_cfg.followups,
1439 drafts: cfg.loop_cfg.drafts,
1440 fork_when_denied: cfg.loop_cfg.fork_when_denied,
1441 fork: OnceLock::new(),
1442 viewer: OnceLock::new(),
1443 checkpoints: Mutex::new(BTreeMap::new()),
1444 writes: WriteStats::default(),
1445 };
1446 repo.self_exclude();
1447 Ok(repo)
1448 }
1449
1450 fn self_exclude(&self) {
1458 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1459 let git_dir = git_dir.trim();
1460 if git_dir.is_empty() {
1461 return;
1462 }
1463 let path = Path::new(git_dir).join("info").join("exclude");
1464 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1465
1466 let wanted = [
1471 format!("/{WORKTREE_DIR}/"),
1472 format!("/{STATE_DIR}/"),
1473 format!("{RECOVERY_MARKER_PREFIX}*"),
1474 ];
1475 let missing: Vec<&String> = wanted
1476 .iter()
1477 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1478 .collect();
1479 if missing.is_empty() {
1480 return;
1481 }
1482
1483 use std::io::Write;
1484 if let Some(parent) = path.parent() {
1485 let _ = std::fs::create_dir_all(parent);
1486 }
1487 let mut block = String::new();
1488 if !existing.is_empty() && !existing.ends_with('\n') {
1489 block.push('\n');
1490 }
1491 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1492 for line in missing {
1493 block.push_str(line);
1494 block.push('\n');
1495 }
1496 if let Ok(mut file) = std::fs::OpenOptions::new()
1497 .create(true)
1498 .append(true)
1499 .open(&path)
1500 {
1501 let _ = file.write_all(block.as_bytes());
1502 }
1503 }
1504
1505 pub fn root(&self) -> &Path {
1506 &self.root
1507 }
1508
1509 pub(crate) fn write_summary(&self) -> WriteSummary {
1510 WriteSummary {
1511 attempted: self.writes.attempted.load(Ordering::Relaxed),
1512 failed: self.writes.failed.load(Ordering::Relaxed),
1513 }
1514 }
1515
1516 pub(crate) fn record_write<T, E>(
1517 &self,
1518 result: std::result::Result<T, E>,
1519 ) -> std::result::Result<T, E> {
1520 self.record_write_outcome(result.is_err());
1521 result
1522 }
1523
1524 pub(crate) fn record_failed_write<T, E>(
1525 &self,
1526 result: std::result::Result<T, E>,
1527 ) -> std::result::Result<T, E> {
1528 if result.is_err() {
1529 self.record_write_outcome(true);
1530 }
1531 result
1532 }
1533
1534 fn record_write_outcome(&self, failed: bool) {
1535 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1536 if failed {
1537 self.writes.failed.fetch_add(1, Ordering::Relaxed);
1538 }
1539 }
1540
1541 pub fn clean(&self, text: &str) -> Result<String> {
1547 let out = style::scrub(text, &self.style);
1548 let bad = style::violations(&out, &self.style);
1549 if !bad.is_empty() {
1550 bail!(
1551 "style gate could not clean text ({}): {}",
1552 bad.join(", "),
1553 style::clip(&out, 300)
1554 );
1555 }
1556 Ok(out)
1557 }
1558
1559 pub fn clean_body(&self, text: &str) -> Result<String> {
1561 self.clean(&style::body(text, &self.style))
1562 }
1563
1564 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1566 self.clean(&style::issue_body(text, &self.style))
1567 }
1568
1569 pub fn clean_title(&self, text: &str) -> Result<String> {
1580 Ok(style::title(&self.clean(text)?, &self.style))
1581 }
1582
1583 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1584 let title = self.record_failed_write(self.clean_title(text))?;
1585 if title.trim().is_empty() {
1586 return self.record_failed_write(Err(spar_err!(
1587 "nothing left of the title after cleaning it"
1588 )));
1589 }
1590 Ok(title)
1591 }
1592
1593 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1594 if self.followups == Followups::Issues {
1595 self.clean_nonempty_title_for_write(text)
1596 } else {
1597 self.clean_title(text)
1598 }
1599 }
1600
1601 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1604 ExecOpts::new()
1605 .cwd(cwd.unwrap_or(&self.root))
1606 .check(check)
1607 .timeout_secs(600)
1608 }
1609
1610 pub fn git(&self, args: &[&str]) -> Result<String> {
1611 self.git_at(None, args)
1612 }
1613
1614 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1615 let argv = git_without_maintenance_argv(args);
1616 proc::run(&argv, &self.git_opts(cwd, true))
1617 }
1618
1619 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1625 let argv = git_without_automation_argv(args);
1626 proc::run(
1627 &argv,
1628 &self.git_opts(Some(cwd), true).stop_descendants(true),
1629 )
1630 }
1631
1632 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1633 let argv = git_without_automation_argv(args);
1634 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1635 .map(|output| output.ok())
1636 }
1637
1638 pub fn git_try(&self, args: &[&str]) -> String {
1640 self.git_try_at(None, args)
1641 }
1642
1643 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1644 let argv = git_without_maintenance_argv(args);
1645 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1646 }
1647
1648 pub fn default_branch(&self, configured: &str) -> String {
1651 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1652 match refname.trim().rsplit('/').next() {
1653 Some(name) if !name.is_empty() => name.to_string(),
1654 _ => configured.to_string(),
1655 }
1656 }
1657
1658 pub fn branch_for_issue(&self, issue: i64) -> String {
1666 format!("{}issue-{issue}", self.branch_prefix)
1667 }
1668
1669 pub fn branch_for_pr(&self, number: i64) -> String {
1670 format!("{}pr-{number}", self.branch_prefix)
1671 }
1672
1673 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1682 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1683 }
1684
1685 fn ledger_path(&self) -> PathBuf {
1686 self.root.join(STATE_DIR).join("branches.json")
1687 }
1688
1689 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1690 std::fs::read_to_string(self.ledger_path())
1691 .ok()
1692 .and_then(|text| serde_json::from_str(&text).ok())
1693 .unwrap_or_default()
1694 }
1695
1696 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1697 let mut data = self.known_branches();
1698 data.insert(
1699 branch.to_string(),
1700 BranchRecord {
1701 kind: kind.to_string(),
1702 number,
1703 },
1704 );
1705 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1706 logdim!("could not record branch {branch}: {e}");
1707 }
1708 }
1709
1710 pub fn forget_branch(&self, branch: &str) {
1711 let mut data = self.known_branches();
1712 if data.remove(branch).is_none() {
1713 return;
1714 }
1715 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1716 logdim!("could not update the branch record: {e}");
1717 }
1718 }
1719
1720 fn worktree_path(&self, name: &str) -> PathBuf {
1723 self.root.join(WORKTREE_DIR).join(name)
1724 }
1725
1726 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1728 let branch = self.branch_for_issue(issue);
1729 let path = self.worktree_path(&format!("issue-{issue}"));
1730
1731 self.refuse_issue_branch_rebuild(issue, base)?;
1732 self.refuse_dirty_worktree(
1733 &path,
1734 &format!("worktree for issue #{issue}"),
1735 Disposable::BuildOutput,
1736 )?;
1737
1738 if !self.branch_deletion_is_safe(&branch)? {
1739 bail!(
1740 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1741 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1742 before retrying."
1743 );
1744 }
1745
1746 if !self.remove_worktree_at(&path)? {
1747 bail!(
1748 "the existing worktree for issue #{issue} could not be removed safely. Its \
1749 branch was kept."
1750 );
1751 }
1752 if !self.delete_branch_if_safe(&branch)? {
1753 bail!(
1754 "the existing branch {branch} changed or remained checked out while its \
1755 worktree was being rebuilt. It was kept."
1756 );
1757 }
1758
1759 if let Some(parent) = path.parent() {
1760 std::fs::create_dir_all(parent)
1761 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1762 }
1763
1764 let path_str = path.display().to_string();
1765 let remote_start = format!("origin/{base}");
1766 let created = self
1767 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1768 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1769
1770 created.map_err(|e| {
1773 spar_err!(
1774 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1775 and does `origin` exist?",
1776 e.last_line()
1777 )
1778 })?;
1779 self.record_branch(&branch, "issue", issue);
1780 Ok((path, branch))
1781 }
1782
1783 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1790 let branch = self.branch_for_issue(issue);
1791 let base_remote_ref = format!("refs/heads/{base}");
1792 let base_tracking_ref = format!("refs/remotes/origin/{base}");
1793 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1794 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1795 .map_err(|e| {
1796 spar_err!(
1797 "could not refresh origin/{base} before checking issue #{issue}: {}",
1798 e.last_line()
1799 )
1800 })?;
1801
1802 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1803 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1804 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1805 bail!(
1806 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1807 pull request accounts for them. Rebuilding it would force push over that \
1808 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1809 it, or delete it with `git push origin --delete {branch}` if the remote \
1810 branch is no longer needed."
1811 );
1812 }
1813 }
1814
1815 if self.fork_when_denied && self.origin_refuses_push() {
1818 match self.existing_fork() {
1819 Ok(Some(fork)) => {
1820 self.fork_branch_lease(&fork, &branch, base)?;
1821 }
1822 Ok(None) => {}
1823 Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
1824 }
1825 }
1826
1827 let local_ref = format!("refs/heads/{branch}");
1828 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1829 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1830 let recorded_pr = self
1831 .known_branches()
1832 .get(&branch)
1833 .is_some_and(|record| record.kind == "pr");
1834 let preserved = ahead == 0
1835 || if recorded_pr {
1836 self.local_branch_is_preserved(&branch)?
1837 } else {
1838 self.pull_request_holds(&branch, &local_ref, base)
1839 };
1840 if !preserved {
1841 let listed = self
1842 .commit_lines(&self.root, &local_ref, base)
1843 .iter()
1844 .map(|line| format!(" {line}"))
1845 .collect::<Vec<_>>()
1846 .join("\n");
1847 bail!(
1848 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1849 no pull request preserves them. Rebuilding it would delete the only copy.\n\
1850 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1851 it, or delete it with `git branch -D {branch}` if it is stale."
1852 );
1853 }
1854 }
1855 Ok(())
1856 }
1857
1858 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1859 let live_ref = format!("refs/heads/{branch}");
1860 let tracking_ref = format!("refs/remotes/origin/{branch}");
1861 let listed = self
1862 .git(&["ls-remote", "--heads", "origin", &live_ref])
1863 .map_err(|e| {
1864 spar_err!(
1865 "could not verify whether origin/{branch} still exists: {}",
1866 e.last_line()
1867 )
1868 })?;
1869
1870 if remote_head_oid(&listed, &live_ref)?.is_some() {
1871 let refspec = format!("+{live_ref}:{tracking_ref}");
1872 self.git(&["fetch", "--no-tags", "origin", &refspec])
1873 .map_err(|e| {
1874 spar_err!(
1875 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1876 e.last_line()
1877 )
1878 })?;
1879 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1880 bail!("origin/{branch} was fetched but its tracking ref is missing");
1881 }
1882 return Ok(Some(tracking_ref));
1883 }
1884
1885 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1886 return Ok(None);
1887 }
1888 let expected = self
1889 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1890 .trim()
1891 .to_string();
1892 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1893 .map_err(|e| {
1894 spar_err!(
1895 "could not discard stale origin/{branch} tracking ref safely: {}",
1896 e.last_line()
1897 )
1898 })?;
1899 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1900 bail!(
1901 "origin/{branch} changed while its stale tracking ref was being removed. It was \
1902 kept."
1903 );
1904 }
1905 Ok(None)
1906 }
1907
1908 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1917 self.prs_for_branch(branch)
1918 .iter()
1919 .any(|pr| self.pr_head_holds(pr.number, refname, base))
1920 }
1921
1922 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1923 let head = format!("refs/spar/pr-head/{number}");
1924 let refspec = format!("+refs/pull/{number}/head:{head}");
1925 if self.git(&["fetch", "origin", &refspec]).is_err() {
1926 return false;
1927 }
1928 let held = self.commits_held_by(refname, base, &head);
1929 self.git_try(&["update-ref", "-d", &head]);
1930 held
1931 }
1932
1933 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1934 let argv = vec![
1935 "git".to_string(),
1936 "merge-base".to_string(),
1937 "--is-ancestor".to_string(),
1938 older.to_string(),
1939 newer.to_string(),
1940 ];
1941 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1942 match out.code {
1943 0 => Ok(true),
1944 1 => Ok(false),
1945 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1946 }
1947 }
1948
1949 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1950 let head = format!("refs/spar/pr-head/{number}");
1951 let refspec = format!("+refs/pull/{number}/head:{head}");
1952 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1953 spar_err!(
1954 "could not verify the immutable head of PR #{number}: {}",
1955 e.last_line()
1956 )
1957 })?;
1958 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1959 self.git_try(&["update-ref", "-d", &head]);
1960 held
1961 }
1962
1963 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1964 let text = self.gh(&[
1965 "pr",
1966 "list",
1967 "--head",
1968 branch,
1969 "--state",
1970 "all",
1971 "--json",
1972 "number,url,title",
1973 ])?;
1974 serde_json::from_str(text.trim())
1975 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1976 }
1977
1978 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1979 let branch_ref = format!("refs/heads/{branch}");
1980 if record.kind == "pr" {
1981 return self.pr_head_contains_checked(record.number, &branch_ref);
1982 }
1983 let prs = self.branch_prs_checked(branch)?;
1984 if prs.is_empty() {
1985 return Ok(false);
1986 }
1987 for pr in prs {
1988 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1989 return Ok(true);
1990 }
1991 }
1992 Ok(false)
1993 }
1994
1995 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1996 let local_ref = format!("refs/heads/{branch}");
1997 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1998 return Ok(true);
1999 }
2000 let oid = self
2001 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2002 .trim()
2003 .to_string();
2004 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2005 if !durable_tip {
2006 let remote_ref = format!("refs/heads/{branch}");
2007 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2008 durable_tip = remote.lines().any(|line| {
2009 line.split_whitespace()
2010 .next()
2011 .is_some_and(|remote_oid| remote_oid == oid)
2012 });
2013 }
2014 if !durable_tip {
2015 if let Some(record) = self.known_branches().get(branch) {
2016 durable_tip = self.branch_is_preserved_checked(branch, record)?;
2017 }
2018 }
2019 if !durable_tip {
2020 return Ok(false);
2021 }
2022 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2023 }
2024
2025 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2029 let local_ref = format!("refs/heads/{branch}");
2030 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2031 return Ok(true);
2032 }
2033 let expected = self
2034 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2035 .trim()
2036 .to_string();
2037 if !self.branch_deletion_is_safe(branch)? {
2038 return Ok(false);
2039 }
2040 let checked_out = self
2041 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2042 .lines()
2043 .any(|line| line == format!("branch {local_ref}"));
2044 if checked_out {
2045 return Ok(false);
2046 }
2047 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2048 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2049 }
2050
2051 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2052 let local_ref = review_ref(number);
2053 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2054 return Ok(true);
2055 }
2056 let oid = self
2057 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2058 .trim()
2059 .to_string();
2060 if !self.pr_head_contains_checked(number, &local_ref)? {
2061 return Ok(false);
2062 }
2063 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2064 }
2065
2066 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2067 let local_ref = review_ref(number);
2068 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2069 return Ok(true);
2070 }
2071 let expected = self
2072 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2073 .trim()
2074 .to_string();
2075 if !self.review_ref_deletion_is_safe(number)? {
2076 return Ok(false);
2077 }
2078 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2079 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2080 }
2081
2082 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2086 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2087 self.git_try(&["rev-list", "--count", &range, "--not", other])
2088 .trim()
2089 == "0"
2090 }
2091
2092 pub fn worktree_remove(&self, issue: i64) -> bool {
2093 let path = self.worktree_path(&format!("issue-{issue}"));
2094 match self.remove_worktree_at(&path) {
2095 Ok(removed) => removed,
2096 Err(error) => {
2097 logdim!(
2098 "kept {} because removal did not reach a confirmed quiet point: {}",
2099 path.display(),
2100 error.last_line()
2101 );
2102 false
2103 }
2104 }
2105 }
2106
2107 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2112 let wanted = std::fs::canonicalize(path)
2113 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2114 if wanted != path {
2119 return Ok(false);
2120 }
2121 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2122 let raw = PathBuf::from(value.trim());
2123 let joined = if raw.is_absolute() {
2124 raw
2125 } else {
2126 cwd.join(raw)
2127 };
2128 std::fs::canonicalize(&joined)
2129 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2130 };
2131 let expected =
2132 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2133 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2134 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2135 let expected = resolve(&self.root, &expected)?;
2136 let actual = resolve(path, &actual)?;
2137 let top = resolve(path, &top)?;
2138 Ok(expected == actual && top == wanted)
2139 }
2140
2141 fn remove_worktree_at_with_force(
2147 &self,
2148 path: &Path,
2149 force: bool,
2150 disposable: Disposable,
2151 ) -> Result<bool> {
2152 let existed = path.exists();
2153 if path.exists() {
2154 match self.worktree_belongs_to_repo(path) {
2155 Ok(true) => {}
2156 Ok(false) => {
2157 logdim!(
2158 "kept {} because it is not a worktree owned by this repository",
2159 path.display()
2160 );
2161 return Ok(false);
2162 }
2163 Err(e) => {
2164 logdim!(
2165 "kept {} because its worktree ownership could not be verified: {}",
2166 path.display(),
2167 e.last_line()
2168 );
2169 return Ok(false);
2170 }
2171 }
2172 if !force {
2173 match self.has_recoverable_work(path, disposable) {
2174 Ok(true) => {
2175 logdim!(
2176 "kept {} because it contains recoverable files or repository state",
2177 path.display()
2178 );
2179 return Ok(false);
2180 }
2181 Err(e) => {
2182 logdim!(
2183 "kept {} because its recoverable state could not be checked: {}",
2184 path.display(),
2185 e.last_line()
2186 );
2187 return Ok(false);
2188 }
2189 Ok(false) => {}
2190 }
2191 }
2192 }
2193 let path_str = path.display().to_string();
2199 let command_ok = if force || disposable == Disposable::ReviewOutput {
2200 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2201 } else {
2202 self.git_try_without_automation(&["worktree", "remove", &path_str])?
2203 };
2204 Ok((command_ok || !existed) && !path.exists())
2205 }
2206
2207 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2208 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2209 }
2210
2211 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2213 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2214 }
2215
2216 fn remove_worktree_at_force(&self, path: &Path) -> bool {
2218 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2219 Ok(removed) => removed,
2220 Err(error) => {
2221 logdim!(
2222 "kept {} because removal did not reach a confirmed quiet point: {}",
2223 path.display(),
2224 error.last_line()
2225 );
2226 false
2227 }
2228 }
2229 }
2230
2231 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2236 if path.exists() && !self.worktree_belongs_to_repo(path)? {
2237 bail!(
2238 "{} is not a worktree owned by this repository, so it was kept",
2239 path.display()
2240 );
2241 }
2242 if path.exists() && self.has_recoverable_work(path, disposable)? {
2243 bail!(
2244 "the verified worktree at {} contains recoverable files or repository state. It \
2245 was kept.",
2246 path.display()
2247 );
2248 }
2249 let path_str = path.display().to_string();
2250 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2251 .map_err(|e| {
2252 e.with_message(format!(
2253 "could not remove the verified worktree at {}: {}. It was kept.",
2254 path.display(),
2255 e.last_line()
2256 ))
2257 })?;
2258 Ok(!path.exists())
2259 }
2260
2261 fn refuse_dirty_worktree(
2262 &self,
2263 path: &Path,
2264 label: &str,
2265 disposable: Disposable,
2266 ) -> Result<()> {
2267 if !path.is_dir() {
2268 return Ok(());
2269 }
2270 let has_files = std::fs::read_dir(path)
2271 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2272 .next()
2273 .is_some();
2274 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2275 spar_err!(
2276 "could not verify whether the existing {label} at {} belongs to this repository, \
2277 so it was kept: {}",
2278 path.display(),
2279 e.last_line()
2280 )
2281 })?;
2282 if !owned {
2283 if has_files {
2284 bail!(
2285 "the existing {label} at {} is not a worktree owned by \
2286 this repository. Refusing to remove it.",
2287 path.display()
2288 );
2289 }
2290 return Ok(());
2291 }
2292 if !path.join(".git").exists() {
2293 if has_files {
2294 bail!(
2295 "the existing {label} at {} is not a readable Git worktree and is not empty. \
2296 Refusing to remove it.",
2297 path.display()
2298 );
2299 }
2300 return Ok(());
2301 }
2302 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2303 spar_err!(
2304 "could not verify whether the existing {label} at {} is clean, so it was kept: \
2305 {}",
2306 path.display(),
2307 e.last_line()
2308 )
2309 })?;
2310 if dirty {
2311 bail!(
2312 "the existing {label} contains uncommitted changes or ignored files at {}. \
2313 Rebuilding it would delete those files.\nCommit or recover them before running this \
2314 command again, or use `spar clean --all` if they are not needed.",
2315 path.display()
2316 );
2317 }
2318 Ok(())
2319 }
2320
2321 pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2324 let head = pr.head_ref_name.clone();
2325 if head.trim().is_empty() {
2326 bail!("PR #{} has no head branch to check out", pr.number);
2327 }
2328 let path = self.worktree_path(&format!("pr-{}", pr.number));
2329 let local = self.branch_for_pr(pr.number);
2330
2331 let unfetched = |e: crate::error::SparError| {
2332 spar_err!(
2333 "could not fetch the branch behind PR #{}: {}",
2334 pr.number,
2335 e.last_line()
2336 )
2337 };
2338 let (start, start_ref, shown) = match target {
2341 PushTarget::Origin => {
2342 self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2343 let start = format!("origin/{head}");
2344 (start.clone(), format!("refs/remotes/origin/{head}"), start)
2345 }
2346 PushTarget::Fork(fork) => {
2349 let start_ref = format!("refs/spar/fork/{head}");
2350 let refspec = format!("+refs/heads/{head}:{start_ref}");
2351 self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2352 .map_err(unfetched)?;
2353 (start_ref.clone(), start_ref, target.describe(&head))
2354 }
2355 };
2356 let local_ref = format!("refs/heads/{local}");
2357 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2358 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2359 if unpushed > 0 {
2360 bail!(
2361 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2362 on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2363 {} and push or recover those commits before running this command again.",
2364 pr.number,
2365 path.display()
2366 );
2367 }
2368 }
2369 self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2370 self.refuse_dirty_worktree(
2371 &path,
2372 &format!("worktree for PR #{}", pr.number),
2373 Disposable::BuildOutput,
2374 )?;
2375 if !self.branch_deletion_is_safe(&local)? {
2376 bail!(
2377 "the existing branch {local} has a tip or reflog-only commit that no surviving \
2378 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2379 before retrying."
2380 );
2381 }
2382 if !self.remove_worktree_at(&path)? {
2383 bail!(
2384 "the existing worktree for PR #{} could not be removed safely. Its branch was \
2385 kept.",
2386 pr.number
2387 );
2388 }
2389 if !self.delete_branch_if_safe(&local)? {
2390 bail!(
2391 "the existing branch {local} changed or remained checked out while the PR \
2392 worktree was being rebuilt. It was kept."
2393 );
2394 }
2395
2396 let path_str = path.display().to_string();
2397 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2398 self.record_branch(&local, "pr", pr.number);
2399 Ok((path, head))
2400 }
2401
2402 fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2405 if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2406 return Ok(());
2407 }
2408 let current =
2409 self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2410 if current.trim() != branch
2411 || self.has_uncommitted_changes(path)?
2412 || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2413 {
2414 return Ok(());
2415 }
2416 static NEXT: AtomicUsize = AtomicUsize::new(0);
2417 let (name, retained) = loop {
2418 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2419 let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2420 let retained = self.worktree_path(&name);
2421 if !retained
2422 .try_exists()
2423 .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2424 && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2425 {
2426 break (name, retained);
2427 }
2428 };
2429 self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2432 if let Err(error) = self.git_at_without_automation(
2433 &self.root,
2434 &[
2435 "worktree",
2436 "move",
2437 &path.display().to_string(),
2438 &retained.display().to_string(),
2439 ],
2440 ) {
2441 let rollback =
2444 self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2445 let detail = rollback
2446 .err()
2447 .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2448 .unwrap_or_default();
2449 bail!(
2450 "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2451 retained.display(),
2452 error.last_line()
2453 );
2454 }
2455 logdim!(
2456 "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2457 retained.display()
2458 );
2459 Ok(())
2460 }
2461
2462 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2472 let path = self.worktree_path(&format!("review-{number}"));
2473 let local_ref = review_ref(number);
2474 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2475
2476 self.refuse_review_worktree_changes(number)?;
2477
2478 if let Some(parent) = path.parent() {
2479 std::fs::create_dir_all(parent)
2480 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2481 }
2482 if !self.remove_review_worktree_at(&path)? {
2487 bail!(
2488 "the existing review worktree for PR #{number} could not be removed safely. Its \
2489 reference was kept."
2490 );
2491 }
2492
2493 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2494 spar_err!(
2495 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2496 every pull request, so this usually means the number is wrong or `origin` does \
2497 not point at the repository the PR is on.",
2498 e.last_line()
2499 )
2500 })?;
2501
2502 let path_str = path.display().to_string();
2503 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2504 Ok(path)
2505 }
2506
2507 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2508 let path = self.worktree_path(&format!("review-{number}"));
2509 if !path.is_dir() {
2510 return Ok(());
2511 }
2512 let local_ref = review_ref(number);
2513 if !self.worktree_belongs_to_repo(&path)? {
2514 return Ok(());
2515 }
2516 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2517 bail!(
2518 "the existing review worktree for PR #{number} has no recorded head at \
2519 {local_ref}. Refusing to rebuild {}.",
2520 path.display()
2521 );
2522 }
2523 let worktree_head = self.head_oid_checked(&path)?;
2524 let recorded_head = self
2525 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2526 .trim()
2527 .to_string();
2528 if worktree_head != recorded_head {
2529 bail!(
2530 "the existing review worktree for PR #{number} has a local commit that is not on \
2531 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2532 {} before retrying.",
2533 path.display()
2534 );
2535 }
2536 self.report_recovery_markers(&path);
2537 self.refuse_dirty_worktree(
2538 &path,
2539 &format!("review worktree for PR #{number}"),
2540 Disposable::ReviewOutput,
2541 )?;
2542 Ok(())
2543 }
2544
2545 fn report_recovery_markers(&self, path: &Path) {
2553 let Ok(entries) = std::fs::read_dir(path) else {
2554 return;
2555 };
2556 for entry in entries.flatten() {
2557 let name = entry.file_name();
2558 let Some(name) = name.to_str() else { continue };
2559 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2560 continue;
2561 }
2562 match std::fs::read_to_string(entry.path()) {
2563 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2564 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2565 }
2566 }
2567 }
2568
2569 pub fn worktree_for_split(
2582 &self,
2583 parent: i64,
2584 index: usize,
2585 start: &str,
2586 ) -> Result<(PathBuf, String)> {
2587 let slot = self.free_split_slot(parent, index)?;
2588 let branch = format!("{}{slot}", self.branch_prefix);
2589 let path = self.worktree_path(&slot);
2590
2591 if let Some(dir) = path.parent() {
2592 std::fs::create_dir_all(dir)
2593 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2594 }
2595 self.refuse_dirty_worktree(
2596 &path,
2597 &format!("worktree for part {index} of PR #{parent}"),
2598 Disposable::BuildOutput,
2599 )?;
2600 if !self.remove_worktree_at(&path)? {
2604 bail!(
2605 "the existing worktree for part {index} of PR #{parent} could not be removed \
2606 safely. No branch was created."
2607 );
2608 }
2609
2610 let path_str = path.display().to_string();
2611 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2612 .map_err(|e| {
2613 spar_err!(
2614 "could not create a worktree for part {index} of #{parent}. {}",
2615 e.last_line()
2616 )
2617 })?;
2618 self.record_branch(&branch, "split", parent);
2621 Ok((path, branch))
2622 }
2623
2624 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2631 for attempt in 1..=SPLIT_SLOTS {
2632 let slot = split_slot(parent, index, attempt);
2633 let branch = format!("{}{slot}", self.branch_prefix);
2634 self.git_try(&["fetch", "origin", &branch]);
2635 if !self.rev_exists(&self.root, &branch)
2636 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2637 {
2638 return Ok(slot);
2639 }
2640 }
2641 bail!(
2642 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2643 names are all taken. Inspect the existing branches and child pull requests. Finish \
2644 recording the earlier split, or remove every retained local worktree and branch, \
2645 child pull request, and remote split branch before starting over.",
2646 self.branch_for_split(parent, index)
2647 )
2648 }
2649
2650 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2656 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2657 Ok(!self
2658 .git(&["ls-remote", "--heads", "origin", &pattern])?
2659 .trim()
2660 .is_empty())
2661 }
2662
2663 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2670 match self.branch_deletion_is_safe(branch) {
2671 Ok(true) => {}
2672 Ok(false) => {
2673 logdim!(
2674 "kept {branch} and {} because no surviving ref preserves its tip",
2675 dir.display()
2676 );
2677 return;
2678 }
2679 Err(error) => {
2680 logdim!(
2681 "kept {branch} and {} because preservation could not be verified: {}",
2682 dir.display(),
2683 error.last_line()
2684 );
2685 return;
2686 }
2687 }
2688 match self.remove_worktree_at(dir) {
2689 Ok(true) => match self.delete_branch_if_safe(branch) {
2690 Ok(true) => self.forget_branch(branch),
2691 Ok(false) => {
2692 logdim!("kept {branch} because its tip or reflog changed before deletion")
2693 }
2694 Err(error) => logdim!(
2695 "kept {branch} because deletion safety could not be rechecked: {}",
2696 error.last_line()
2697 ),
2698 },
2699 Ok(false) => {}
2700 Err(error) => logdim!(
2701 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2702 dir.display(),
2703 error.last_line()
2704 ),
2705 }
2706 }
2707
2708 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2714 let record = self.known_branches().get(branch).cloned();
2715 if record.is_none_or(|record| record.kind != "split") {
2716 logdim!("kept {branch} because no split branch record proves ownership");
2717 return false;
2718 }
2719 let local_ref = format!("refs/heads/{branch}");
2720 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2721 Ok(value) => value.trim().to_string(),
2722 Err(error) => {
2723 logdim!(
2724 "kept {branch} because its tip could not be checked: {}",
2725 error.last_line()
2726 );
2727 return false;
2728 }
2729 };
2730 if expected != disposable_head {
2731 logdim!("kept {branch} because it moved beyond the disposable slice");
2732 return false;
2733 }
2734 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2735 Ok(true) => {}
2736 Ok(false) => {
2737 logdim!(
2738 "kept {branch} because its reflog contains work outside the disposable slice"
2739 );
2740 return false;
2741 }
2742 Err(error) => {
2743 logdim!(
2744 "kept {branch} because its reflog could not be checked: {}",
2745 error.last_line()
2746 );
2747 return false;
2748 }
2749 }
2750 match self.head_oid_checked(dir) {
2751 Ok(head) if head == disposable_head => {}
2752 Ok(_) => {
2753 logdim!(
2754 "kept {branch} and {} because the worktree moved beyond the disposable slice",
2755 dir.display()
2756 );
2757 return false;
2758 }
2759 Err(error) => {
2760 logdim!(
2761 "kept {branch} and {} because its head could not be checked: {}",
2762 dir.display(),
2763 error.last_line()
2764 );
2765 return false;
2766 }
2767 }
2768 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2769 Ok(true) => {}
2770 Ok(false) => return false,
2771 Err(error) => {
2772 logdim!(
2773 "kept {branch} and {} because the disposable slice could not be verified: {}",
2774 dir.display(),
2775 error.last_line()
2776 );
2777 return false;
2778 }
2779 }
2780 if let Err(error) =
2781 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2782 {
2783 logdim!(
2784 "kept {branch} because its exact disposable tip could not be deleted: {}",
2785 error.last_line()
2786 );
2787 return false;
2788 }
2789 match self.exact_ref_exists_checked(&self.root, &local_ref) {
2790 Ok(false) => {
2791 self.forget_branch(branch);
2792 true
2793 }
2794 Ok(true) => {
2795 logdim!("kept {branch} because its ref still exists after deletion");
2796 false
2797 }
2798 Err(error) => {
2799 logdim!(
2800 "kept the branch record for {branch} because deletion could not be verified: {}",
2801 error.last_line()
2802 );
2803 false
2804 }
2805 }
2806 }
2807
2808 pub fn release_review_worktree(&self, number: i64) {
2809 let path = self.worktree_path(&format!("review-{number}"));
2810 match self.review_ref_deletion_is_safe(number) {
2811 Ok(true) => {}
2812 Ok(false) => {
2813 logdim!(
2814 "kept {} because no surviving ref preserves its review history",
2815 path.display()
2816 );
2817 return;
2818 }
2819 Err(error) => {
2820 logdim!(
2821 "kept {} because review history could not be verified: {}",
2822 path.display(),
2823 error.last_line()
2824 );
2825 return;
2826 }
2827 }
2828 match self.remove_worktree_at(&path) {
2829 Ok(true) => match self.delete_review_ref_if_safe(number) {
2830 Ok(true) => {}
2831 Ok(false) => logdim!(
2832 "kept {} because its review history changed before deletion",
2833 review_ref(number)
2834 ),
2835 Err(error) => logdim!(
2836 "kept {} because deletion safety could not be rechecked: {}",
2837 review_ref(number),
2838 error.last_line()
2839 ),
2840 },
2841 Ok(false) => {}
2842 Err(error) => logdim!(
2843 "kept {} because removal did not reach a confirmed quiet point: {}",
2844 path.display(),
2845 error.last_line()
2846 ),
2847 }
2848 }
2849
2850 pub(crate) fn release_review_worktree_checked(
2853 &self,
2854 number: i64,
2855 checkpoint: &WorktreeCheckpoint,
2856 ) -> Result<()> {
2857 let path = self.worktree_path(&format!("review-{number}"));
2858 self.require_unchanged_worktree(
2859 &path,
2860 checkpoint,
2861 &format!("review worktree for PR #{number}"),
2862 )?;
2863 if !self.review_ref_deletion_is_safe(number)? {
2864 bail!(
2865 "the review reference for PR #{number} has reflog-only recovery history. The \
2866 worktree and reference were kept."
2867 );
2868 }
2869 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2870 bail!(
2871 "the verified review worktree at {} could not be removed, so its reference was \
2872 kept",
2873 path.display()
2874 );
2875 }
2876 if !self.delete_review_ref_if_safe(number)? {
2877 bail!(
2878 "the review reference for PR #{number} changed before deletion. The reference was \
2879 kept."
2880 );
2881 }
2882 Ok(())
2883 }
2884
2885 pub fn release_pr_worktree(&self, number: i64) -> bool {
2886 let path = self.worktree_path(&format!("pr-{number}"));
2887 let local = self.branch_for_pr(number);
2888 match self.branch_deletion_is_safe(&local) {
2889 Ok(true) => {}
2890 Ok(false) => {
2891 logdim!(
2892 "kept {local} and {} because no surviving ref preserves its tip",
2893 path.display()
2894 );
2895 return false;
2896 }
2897 Err(error) => {
2898 logdim!(
2899 "kept {local} and {} because preservation could not be verified: {}",
2900 path.display(),
2901 error.last_line()
2902 );
2903 return false;
2904 }
2905 }
2906 match self.remove_worktree_at(&path) {
2907 Ok(true) => match self.delete_branch_if_safe(&local) {
2908 Ok(true) => {
2909 self.forget_branch(&local);
2910 true
2911 }
2912 Ok(false) => {
2913 logdim!("kept {local} because its tip or reflog changed before deletion");
2914 false
2915 }
2916 Err(error) => {
2917 logdim!(
2918 "kept {local} because deletion safety could not be rechecked: {}",
2919 error.last_line()
2920 );
2921 false
2922 }
2923 },
2924 Ok(false) => false,
2925 Err(error) => {
2926 logdim!(
2927 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
2928 path.display(),
2929 error.last_line()
2930 );
2931 false
2932 }
2933 }
2934 }
2935
2936 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
2947 let remote = format!("origin/{base}");
2948 if self.rev_exists(cwd, &remote) {
2949 return remote;
2950 }
2951 if self.rev_exists(cwd, base) {
2952 logdim!("origin/{base} does not resolve, comparing against local {base}");
2953 return base.to_string();
2954 }
2955 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
2956 remote
2957 }
2958
2959 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
2960 let spec = format!("{refname}^{{commit}}");
2961 !self
2962 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
2963 .trim()
2964 .is_empty()
2965 }
2966
2967 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2968 let range = format!("{}..HEAD", self.base_ref(cwd, base));
2969 !self
2970 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2971 .trim()
2972 .is_empty()
2973 }
2974
2975 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2976 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2977 Ok(found.lines().any(|line| line.trim() == refname))
2978 }
2979
2980 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2981 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2982 count.trim().parse::<usize>().map_err(|e| {
2983 spar_err!(
2984 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2985 count.trim()
2986 )
2987 })
2988 }
2989
2990 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2991 let remote = format!("refs/remotes/origin/{base}");
2992 if self.exact_ref_exists_checked(cwd, &remote)? {
2993 return Ok(remote);
2994 }
2995 let local = format!("refs/heads/{base}");
2996 if self.exact_ref_exists_checked(cwd, &local)? {
2997 return Ok(local);
2998 }
2999 bail!("neither origin/{base} nor local branch {base} resolves")
3000 }
3001
3002 pub(crate) fn commit_count_checked(
3003 &self,
3004 cwd: &Path,
3005 refname: &str,
3006 base: &str,
3007 ) -> Result<usize> {
3008 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3009 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3010 count.trim().parse::<usize>().map_err(|e| {
3011 spar_err!(
3012 "git returned an invalid commit count for {range}: {:?} ({e})",
3013 count.trim()
3014 )
3015 })
3016 }
3017
3018 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3019 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3020 }
3021
3022 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3023 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3024 let head = head.trim().to_string();
3025 if head.is_empty() {
3026 bail!("git returned an empty HEAD for {}", cwd.display());
3027 }
3028 Ok(head)
3029 }
3030
3031 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3036 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3037 self.local_branch_is_preserved(branch.trim())
3038 }
3039
3040 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3043 let known = self.known_branches();
3044 let Some(record) = known.get(branch) else {
3045 return Ok(false);
3046 };
3047 self.branch_is_preserved_checked(branch, record)
3048 }
3049
3050 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3052 has_uncommitted_work(cwd)
3053 }
3054
3055 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3058 repository_has_recoverable_work_with(cwd, true, disposable)
3059 }
3060
3061 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3063 let attributes = attribute_state(cwd)?;
3064 Ok(WorktreeBaseline {
3065 attributes,
3066 ignored_untracked: ignored_untracked_state(cwd)?,
3067 git_state: safe_git_state(cwd)?,
3068 })
3069 }
3070
3071 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3074 let attributes = attribute_state(cwd)?;
3075 Ok(WorktreeCheckpoint {
3076 path: std::fs::canonicalize(cwd)
3077 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3078 attributes,
3079 git_state: safe_git_state(cwd)?,
3080 ignored_untracked: ignored_untracked_state(cwd)?,
3081 })
3082 }
3083
3084 pub(crate) fn require_unchanged_worktree(
3087 &self,
3088 cwd: &Path,
3089 checkpoint: &WorktreeCheckpoint,
3090 label: &str,
3091 ) -> Result<()> {
3092 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3093 crate::error::SparError::uncertain_write(format!(
3094 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3095 cwd.display()
3096 ))
3097 })?;
3098 if resolved != checkpoint.path {
3099 return Err(uncertain_worktree_change(
3100 cwd,
3101 format!(
3102 "the {label} moved from {} to {} during inspection. It was kept.",
3103 checkpoint.path.display(),
3104 resolved.display()
3105 ),
3106 ));
3107 }
3108 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3109 uncertain_worktree_change(
3110 cwd,
3111 format!(
3112 "could not verify attribute files in the {label} at {}: {}. It was kept.",
3113 cwd.display(),
3114 e.last_line()
3115 ),
3116 )
3117 })?;
3118 if attributes != checkpoint.attributes {
3119 return Err(uncertain_worktree_change(
3120 cwd,
3121 format!(
3122 "attribute files in the {label} at {} changed during inspection. It was \
3123 kept for recovery.",
3124 cwd.display()
3125 ),
3126 ));
3127 }
3128 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3129 uncertain_worktree_change(
3130 cwd,
3131 format!(
3132 "could not verify the Git state of the {label} at {}: {}. It was kept.",
3133 cwd.display(),
3134 e.last_line()
3135 ),
3136 )
3137 })?;
3138 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3139 uncertain_worktree_change(
3140 cwd,
3141 format!(
3142 "could not verify untracked files in the {label} at {}: {}. It was kept.",
3143 cwd.display(),
3144 e.last_line()
3145 ),
3146 )
3147 })?;
3148 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3149 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3153 let tracked = checkpoint.git_state.describe_difference(&git_state);
3154 let untracked = (!leavings.condemning.is_empty()).then(|| {
3155 format!(
3156 "untracked file(s) changed: {}",
3157 list_paths(&leavings.condemning)
3158 )
3159 });
3160 let Some(reason) = join_reasons(tracked, untracked) else {
3161 return Ok(());
3162 };
3163 Err(uncertain_worktree_change(
3164 cwd,
3165 format!(
3166 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3167 for recovery.",
3168 cwd.display()
3169 ),
3170 ))
3171 }
3172
3173 pub(crate) fn refuse_new_ignored_files(
3177 &self,
3178 cwd: &Path,
3179 baseline: &WorktreeBaseline,
3180 ) -> Result<()> {
3181 self.allow_ignored_files(cwd, baseline).map(drop)
3182 }
3183
3184 pub(crate) fn refuse_failed_edit_leavings(
3186 &self,
3187 cwd: &Path,
3188 baseline: &WorktreeBaseline,
3189 ) -> Result<()> {
3190 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3191 .map(drop)
3192 }
3193
3194 pub(crate) fn refuse_read_only_leavings(
3196 &self,
3197 cwd: &Path,
3198 baseline: &WorktreeBaseline,
3199 ) -> Result<()> {
3200 self.refuse_changed_attributes(cwd, baseline)?;
3201 let after = ignored_untracked_state(cwd).map_err(|e| {
3202 uncertain_worktree_change(
3203 cwd,
3204 format!(
3205 "could not verify untracked files in {} after a read-only call: {}. The \
3206 worktree was kept for recovery.",
3207 cwd.display(),
3208 e.last_line()
3209 ),
3210 )
3211 })?;
3212 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3213 if leavings.condemning.is_empty() {
3214 return Ok(());
3215 }
3216 Err(uncertain_worktree_change(
3217 cwd,
3218 format!(
3219 "the read-only call left untracked file(s) in {} that the project does not \
3220 ignore: {}. The worktree was kept for recovery.",
3221 cwd.display(),
3222 list_paths(&leavings.condemning)
3223 ),
3224 ))
3225 }
3226
3227 fn allow_ignored_files(
3230 &self,
3231 cwd: &Path,
3232 baseline: &WorktreeBaseline,
3233 ) -> Result<AllowedArtifacts> {
3234 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3235 }
3236
3237 fn check_new_ignored_files(
3238 &self,
3239 cwd: &Path,
3240 baseline: &WorktreeBaseline,
3241 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3242 ) -> Result<AllowedArtifacts> {
3243 self.refuse_changed_attributes(cwd, baseline)?;
3244 let after = ignored_untracked_state(cwd).map_err(|e| {
3245 uncertain_worktree_change(
3246 cwd,
3247 format!(
3248 "could not verify untracked files in {} after editing: {}. The worktree was \
3249 kept for recovery.",
3250 cwd.display(),
3251 e.last_line()
3252 ),
3253 )
3254 })?;
3255 let changed = baseline.ignored_untracked.changed_paths(&after);
3256 if changed.is_empty() {
3257 return Ok(AllowedArtifacts::default());
3258 }
3259 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3260 .into_iter()
3261 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3262 if changed.is_empty() {
3263 return Ok(AllowedArtifacts::split(ignored, &after));
3264 }
3265 let listed = list_paths(&changed);
3266 Err(uncertain_worktree_change(
3267 cwd,
3268 format!(
3269 "the editing call created or changed untracked or ignored file(s) in {} that \
3270 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3271 recovery.",
3272 cwd.display()
3273 ),
3274 ))
3275 }
3276
3277 pub(crate) fn refuse_changed_existing_untracked(
3282 &self,
3283 cwd: &Path,
3284 baseline: &WorktreeBaseline,
3285 ) -> Result<()> {
3286 self.check_changed_existing_untracked(cwd, baseline)
3287 .map(drop)
3288 }
3289
3290 fn allow_changed_ignored_artifacts(
3291 &self,
3292 cwd: &Path,
3293 baseline: &WorktreeBaseline,
3294 ) -> Result<AllowedArtifacts> {
3295 self.check_changed_existing_untracked(cwd, baseline)
3296 }
3297
3298 fn check_changed_existing_untracked(
3299 &self,
3300 cwd: &Path,
3301 baseline: &WorktreeBaseline,
3302 ) -> Result<AllowedArtifacts> {
3303 self.refuse_changed_attributes(cwd, baseline)?;
3304 let after = ignored_untracked_state(cwd).map_err(|e| {
3305 uncertain_worktree_change(
3306 cwd,
3307 format!(
3308 "could not verify existing untracked files in {} after editing: {}. The \
3309 worktree was kept for recovery.",
3310 cwd.display(),
3311 e.last_line()
3312 ),
3313 )
3314 })?;
3315 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3316 if changed.is_empty() {
3317 return Ok(AllowedArtifacts::default());
3318 }
3319 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3320 .into_iter()
3321 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3322 if changed.is_empty() {
3323 return Ok(AllowedArtifacts::split(ignored, &after));
3324 }
3325 let listed = list_paths(&changed);
3326 Err(uncertain_worktree_change(
3327 cwd,
3328 format!(
3329 "the editing call changed or deleted existing untracked file(s) in {}: \
3330 {listed}. The worktree was kept for recovery.",
3331 cwd.display()
3332 ),
3333 ))
3334 }
3335
3336 pub(crate) fn refuse_unrepresented_tracked_changes(
3343 &self,
3344 cwd: &Path,
3345 baseline: &WorktreeBaseline,
3346 ) -> Result<()> {
3347 self.refuse_changed_attributes(cwd, baseline)?;
3348 let after = safe_git_state(cwd).map_err(|e| {
3349 uncertain_worktree_change(
3350 cwd,
3351 format!(
3352 "could not verify tracked files in {} after editing: {}. The worktree was \
3353 kept for recovery.",
3354 cwd.display(),
3355 e.last_line()
3356 ),
3357 )
3358 })?;
3359 let mut changed = Vec::new();
3360 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3361 uncertain_worktree_change(
3362 cwd,
3363 format!(
3364 "could not record untracked files before verifying transformed content in {}: \
3365 {}. The worktree was kept for recovery.",
3366 cwd.display(),
3367 e.last_line()
3368 ),
3369 )
3370 })?;
3371 let mut filter_was_run = false;
3372 let mut filter_problem = None;
3373 let mut repositories: BTreeSet<PathBuf> =
3374 baseline.git_state.repositories.keys().cloned().collect();
3375 repositories.extend(after.repositories.keys().cloned());
3376 'repositories: for repository_path in repositories {
3377 let before_repository = baseline.git_state.repositories.get(&repository_path);
3378 let after_repository = after.repositories.get(&repository_path);
3379 if before_repository.is_none() || after_repository.is_none() {
3380 changed.push(repository_path.clone());
3381 continue;
3382 }
3383 if before_repository.map(|repository| &repository.gitlinks)
3384 != after_repository.map(|repository| &repository.gitlinks)
3385 {
3386 changed.push(repository_path.join("<gitlinks>"));
3387 }
3388 let mut paths = BTreeSet::new();
3389 if let Some(repository) = before_repository {
3390 paths.extend(repository.tracked.keys().cloned());
3391 }
3392 if let Some(repository) = after_repository {
3393 paths.extend(repository.tracked.keys().cloned());
3394 }
3395 for path in paths {
3396 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3397 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3398 let worktree_changed =
3399 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3400 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3401 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3402 if !worktree_changed {
3403 continue;
3404 }
3405 if !index_changed {
3406 changed.push(repository_path.join(&path));
3407 continue;
3408 }
3409 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3410 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3411 let Some(current_entry) = current else {
3412 continue;
3413 };
3414 let Some(current_worktree) = current_worktree else {
3415 continue;
3416 };
3417 let content_changed =
3418 before_worktree.map(|file| file.content) != Some(current_worktree.content);
3419 let mode_changed = before_worktree.map(|file| file.mode.as_str())
3420 != Some(current_worktree.mode.as_str());
3421 let repository = cwd.join(&repository_path);
3422 let represented_content = if content_changed {
3423 filter_was_run = true;
3424 let result =
3425 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
3426 self.refuse_changed_attributes(cwd, baseline)?;
3427 match result {
3428 Ok(expected) => expected == current_worktree.content,
3429 Err(error) => {
3430 filter_problem = Some(format!(
3431 "could not verify transformed content for {:?}: {}",
3432 repository_path.join(&path),
3433 error.last_line()
3434 ));
3435 false
3436 }
3437 }
3438 } else {
3439 true
3440 };
3441 let represented_mode =
3442 !mode_changed || current_worktree.mode == current_entry.index_mode;
3443 if !represented_content || !represented_mode {
3444 changed.push(repository_path.join(&path));
3445 }
3446 if filter_problem.is_some() {
3447 break 'repositories;
3448 }
3449 }
3450 }
3451 if filter_was_run {
3452 self.refuse_changed_attributes(cwd, baseline)?;
3453 let verified = safe_git_state(cwd).map_err(|e| {
3454 uncertain_worktree_change(
3455 cwd,
3456 format!(
3457 "could not recheck tracked files after verifying transformed content in \
3458 {}: {}. The worktree was kept for recovery.",
3459 cwd.display(),
3460 e.last_line()
3461 ),
3462 )
3463 })?;
3464 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3465 uncertain_worktree_change(
3466 cwd,
3467 format!(
3468 "could not recheck untracked files after verifying transformed content \
3469 in {}: {}. The worktree was kept for recovery.",
3470 cwd.display(),
3471 e.last_line()
3472 ),
3473 )
3474 })?;
3475 if verified != after
3476 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3477 {
3478 return Err(uncertain_worktree_change(
3479 cwd,
3480 "a content filter changed the worktree while SPAR verified the managed \
3481 commit. The worktree was kept for recovery.",
3482 ));
3483 }
3484 self.refuse_changed_existing_untracked(cwd, baseline)?;
3485 }
3486 if let Some(problem) = filter_problem {
3487 return Err(uncertain_worktree_change(
3488 cwd,
3489 format!("{problem}. The worktree was kept for recovery."),
3490 ));
3491 }
3492 if changed.is_empty() {
3493 return Ok(());
3494 }
3495 let listed = list_paths(&changed);
3496 Err(uncertain_worktree_change(
3497 cwd,
3498 format!(
3499 "the editing call changed tracked working-file bytes, modes, repositories, or \
3500 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3501 recovery."
3502 ),
3503 ))
3504 }
3505
3506 pub(crate) fn refuse_changed_attributes(
3507 &self,
3508 cwd: &Path,
3509 baseline: &WorktreeBaseline,
3510 ) -> Result<()> {
3511 let after = attribute_state(cwd).map_err(|e| {
3512 uncertain_worktree_change(
3513 cwd,
3514 format!(
3515 "could not verify attribute files in {} after editing: {}. The worktree was \
3516 kept for recovery.",
3517 cwd.display(),
3518 e.last_line()
3519 ),
3520 )
3521 })?;
3522 if after == baseline.attributes {
3523 return Ok(());
3524 }
3525 Err(uncertain_worktree_change(
3526 cwd,
3527 format!(
3528 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3529 refused to run a Git operation that could select a new external filter.",
3530 cwd.display()
3531 ),
3532 ))
3533 }
3534
3535 pub(crate) fn commit_pending_changes(
3540 &self,
3541 cwd: &Path,
3542 baseline: &WorktreeBaseline,
3543 preferred_subject: &str,
3544 fallback_subject: &str,
3545 ) -> Result<bool> {
3546 let mut artifacts = IgnoredArtifacts::default();
3547 self.refuse_changed_attributes(cwd, baseline)?;
3548 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3549 refuse_unsafe_index_flags(cwd)?;
3550 if !self.has_uncommitted_changes(cwd)? {
3551 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3552 artifacts.report(cwd);
3553 return Ok(false);
3554 }
3555 self.stage_managed_changes(cwd, baseline).map_err(|e| {
3556 e.with_message(format!(
3557 "could not stage changes in {}: {}",
3558 cwd.display(),
3559 e.last_line()
3560 ))
3561 })?;
3562 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3565 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3566 if !changed_gitlinks.is_empty() {
3567 let listed = list_paths(&changed_gitlinks);
3568 bail!(
3569 "the editing call added or changed a gitlink at {listed}. It was staged but not \
3570 committed because the referenced repository objects might exist only inside \
3571 this worktree. The worktree was kept for recovery."
3572 );
3573 }
3574 let mut subject = self.clean_title(preferred_subject)?;
3575 if subject.trim().is_empty() {
3576 subject = self.clean_title(fallback_subject)?;
3577 }
3578 self.commit_staged_changes(cwd, &subject).map_err(|e| {
3579 e.with_message(format!(
3580 "could not commit changes in {}: {}. The staged files were kept.",
3581 cwd.display(),
3582 e.last_line()
3583 ))
3584 })?;
3585 if has_tracked_or_staged_work(cwd)? {
3586 bail!(
3587 "the commit in {} left additional uncommitted files. They were kept for \
3588 recovery.",
3589 cwd.display()
3590 );
3591 }
3592 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3593 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3594 artifacts.report(cwd);
3595 Ok(true)
3596 }
3597
3598 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3599 let after = ignored_untracked_state(cwd)?;
3600 self.git_at_without_automation(cwd, &["add", "-u"])?;
3601 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3602 if paths.is_empty() {
3603 return Ok(());
3604 }
3605 let mut input = Vec::new();
3606 for path in paths {
3607 input.extend(os_str_bytes(path.as_os_str())?);
3608 input.push(0);
3609 }
3610 let argv = git_without_automation_argv(&[
3611 "--literal-pathspecs",
3612 "add",
3613 "--pathspec-from-file=-",
3614 "--pathspec-file-nul",
3615 ]);
3616 proc::run_with_input_bytes(
3617 &argv,
3618 &self.git_opts(Some(cwd), true).stop_descendants(true),
3619 &input,
3620 )?;
3621 Ok(())
3622 }
3623
3624 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3627 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3628 .map(|_| ())
3629 }
3630
3631 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3638 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3639 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3640 .trim()
3641 .parse()
3642 .unwrap_or(0)
3643 }
3644
3645 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3649 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3650 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3651 .lines()
3652 .map(str::to_string)
3653 .collect()
3654 }
3655
3656 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3671 let ancestor = self
3672 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3673 .is_ok();
3674 if !ancestor {
3675 return None;
3676 }
3677 let range = format!("{earlier}..{later}");
3678 Some(
3679 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3680 .lines()
3681 .map(str::to_string)
3682 .collect(),
3683 )
3684 }
3685
3686 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3689 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3690 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3691 .lines()
3692 .map(str::trim)
3693 .filter(|line| !line.is_empty())
3694 .map(str::to_string)
3695 .collect()
3696 }
3697
3698 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3714 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3715 self.git_try_at(
3716 Some(cwd),
3717 &["diff", "--name-only", "--no-renames", "-z", &range],
3718 )
3719 .split('\0')
3720 .filter(|path| !path.is_empty())
3721 .map(str::to_string)
3722 .collect()
3723 }
3724
3725 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3728 self.git_try_at(
3729 Some(cwd),
3730 &[
3731 "diff",
3732 "--name-only",
3733 "--no-renames",
3734 "-z",
3735 &format!("{from}..{to}"),
3736 ],
3737 )
3738 .split('\0')
3739 .filter(|path| !path.is_empty())
3740 .map(str::to_string)
3741 .collect()
3742 }
3743
3744 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3747 let base_ref = self.base_ref(cwd, base);
3748 let out = self
3749 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3750 .map_err(|e| {
3751 spar_err!(
3752 "could not find where {refname} and {base_ref} diverged. {}",
3753 e.last_line()
3754 )
3755 })?;
3756 let sha = out.trim().to_string();
3757 if sha.is_empty() {
3758 bail!("{refname} and {base_ref} share no history");
3759 }
3760 Ok(sha)
3761 }
3762
3763 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3764 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3765 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3766 full.trim().to_string()
3767 }
3768
3769 pub fn rewrite_commits_if_needed(
3782 &self,
3783 cwd: &Path,
3784 base: &str,
3785 floor: Option<&str>,
3786 ) -> Result<()> {
3787 let base_ref = self.base_ref(cwd, base);
3788 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3789 self.report_commits_left_alone(cwd, &base_ref, floor);
3790
3791 let range = format!("{floor}..HEAD");
3792 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3793
3794 let offenders = raw
3795 .split('\x1e')
3796 .filter_map(|entry| entry.split_once('\0'))
3797 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3798 .count();
3799 if offenders == 0 {
3800 return Ok(());
3801 }
3802 logdim!("{offenders} commit message(s) violated style rules, rewriting");
3803
3804 let exe = self_binary()?;
3805 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3806
3807 let argv: Vec<String> = [
3808 "git",
3809 "filter-branch",
3810 "-f",
3811 "--msg-filter",
3812 &filter,
3813 &range,
3814 ]
3815 .iter()
3816 .map(|s| s.to_string())
3817 .collect();
3818 let opts = ExecOpts::new()
3819 .cwd(cwd)
3820 .check(false)
3821 .timeout_secs(600)
3822 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3823 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3824 .env(
3825 "SPAR_BAN_AI_ATTRIBUTION",
3826 bool_env(self.style.ban_ai_attribution),
3827 );
3828 let _ = proc::run(&argv, &opts);
3829
3830 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3831 if !style::violations(&after, &self.style).is_empty() {
3832 bail!(
3833 "commit messages still violate style rules after a rewrite in {}.",
3834 cwd.display()
3835 );
3836 }
3837 Ok(())
3838 }
3839
3840 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3845 if floor == base_ref {
3846 return;
3847 }
3848 let raw = self.git_try_at(
3849 Some(cwd),
3850 &[
3851 "log",
3852 &format!("{base_ref}..{floor}"),
3853 "--format=%H%x00%B%x1e",
3854 ],
3855 );
3856 let left: Vec<&str> = raw
3857 .split('\x1e')
3858 .filter_map(|entry| entry.split_once('\0'))
3859 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3860 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3861 .collect();
3862 if left.is_empty() {
3863 return;
3864 }
3865 logdim!(
3866 "{} commit message(s) already on the branch break the style rules and were left \
3867 alone: {}. spar does not rewrite commits it did not write.",
3868 left.len(),
3869 left.join(", ")
3870 );
3871 }
3872
3873 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3888 self.push_to(cwd, &PushTarget::Origin, branch, expected)
3889 }
3890
3891 pub fn push_to(
3893 &self,
3894 cwd: &Path,
3895 target: &PushTarget,
3896 branch: &str,
3897 expected: Option<&str>,
3898 ) -> Result<()> {
3899 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3900 if let Some(head) = expected {
3901 self.refuse_push_that_drops(cwd, head)?;
3902 }
3903 let pushed = self
3904 .git_push(cwd, target, branch, expected)
3905 .map_err(|e| push_error(target, branch, &e));
3906 self.record_write(pushed)
3907 }
3908
3909 pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
3921 let origin = PushTarget::Origin;
3922 let refused = match self.git_push(cwd, &origin, branch, None) {
3923 Ok(()) => return self.record_write(Ok(origin)),
3924 Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
3925 Err(e) => {
3926 let err = push_error(&origin, branch, &e);
3927 let err = if push_denial(&e.to_string()).is_some() {
3928 spar_err!(
3929 "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
3930 instead."
3931 )
3932 } else {
3933 err
3934 };
3935 return self.record_write(Err(err));
3936 }
3937 };
3938 self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
3939 }
3940
3941 fn publish_on_fork(
3942 &self,
3943 cwd: &Path,
3944 branch: &str,
3945 base: &str,
3946 refused: &crate::error::SparError,
3947 ) -> Result<PushTarget> {
3948 let fork = self.fork().map_err(|e| {
3949 spar_err!(
3950 "{} spar could not publish it from a fork instead: {}",
3951 push_error(&PushTarget::Origin, branch, refused),
3952 e.last_line()
3953 )
3954 })?;
3955 let lease = self.fork_branch_lease(&fork, branch, base)?;
3956 let refused = refused.to_string();
3957 log!(
3958 "{} Publishing {branch} from the fork {} instead.",
3959 push_denial(&refused).unwrap_or("origin refused the push."),
3960 fork.full_name
3961 );
3962 let target = PushTarget::Fork(fork);
3963 self.git_push(cwd, &target, branch, lease.as_deref())
3966 .map_err(|e| push_error(&target, branch, &e))?;
3967 Ok(target)
3968 }
3969
3970 fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
3978 let live_ref = format!("refs/heads/{branch}");
3979 let unreadable = |e: crate::error::SparError| {
3980 spar_err!(
3981 "could not check whether the fork {} already has {branch}. {}",
3982 fork.full_name,
3983 e.last_line()
3984 )
3985 };
3986 let listed = self
3987 .git(&["ls-remote", "--heads", &fork.url, &live_ref])
3988 .map_err(unreadable)?;
3989 let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
3990 return Ok(None);
3991 };
3992 let local = format!("refs/spar/fork/{branch}");
3993 self.git(&[
3994 "fetch",
3995 "--no-tags",
3996 &fork.url,
3997 &format!("+{live_ref}:{local}"),
3998 ])
3999 .map_err(unreadable)?;
4000 let ahead = self.commit_count_checked(&self.root, &local, base);
4001 let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4002 self.git_try(&["update-ref", "-d", &local]);
4003 let ahead = ahead?;
4004 if !held {
4005 bail!(
4006 "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4007 and no pull request accounts for them. Publishing over it would lose that work.\n\
4008 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4009 run spar again.",
4010 fork.full_name,
4011 fork.url
4012 );
4013 }
4014 Ok(Some(tip))
4015 }
4016
4017 fn git_push(
4018 &self,
4019 cwd: &Path,
4020 target: &PushTarget,
4021 branch: &str,
4022 expected: Option<&str>,
4023 ) -> Result<()> {
4024 let refspec = format!("HEAD:refs/heads/{branch}");
4025 let lease = match (expected, target) {
4026 (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4027 (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4031 (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4032 };
4033 self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4034 .map(|_| ())
4035 }
4036
4037 fn fork(&self) -> Result<Fork> {
4044 match self.existing_fork() {
4045 Ok(Some(fork)) => return Ok(fork),
4046 Ok(None) => {}
4047 Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4048 }
4049 let answer = self
4050 .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4051 .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4052 let fork = fork_from_api(&answer, &self.origin_push_url())?;
4053 self.wait_for_fork(&fork)?;
4054 Ok(self.fork.get_or_init(|| fork).clone())
4055 }
4056
4057 fn existing_fork(&self) -> Result<Option<Fork>> {
4060 if let Some(fork) = self.fork.get() {
4061 return Ok(Some(fork.clone()));
4062 }
4063 let answer = self
4064 .gh(&[
4065 "api",
4066 "graphql",
4067 "-F",
4068 "owner={owner}",
4069 "-F",
4070 "name={repo}",
4071 "-f",
4072 "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4073 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4074 login } sshUrl url } } } }",
4075 "--jq",
4076 ".data.repository.forks.nodes",
4077 ])
4078 .map_err(|e| {
4079 spar_err!(
4080 "could not look for a fork of this repository. {}",
4081 e.last_line()
4082 )
4083 })?;
4084 let found = fork_from_listing(&answer, &self.origin_push_url())?;
4085 Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4086 }
4087
4088 fn origin_refuses_push(&self) -> bool {
4092 self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4093 .trim()
4094 == "false"
4095 }
4096
4097 fn origin_push_url(&self) -> String {
4098 self.git_try(&["remote", "get-url", "--push", "origin"])
4099 }
4100
4101 pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4106 if !pr.is_cross_repository {
4107 return Some(PushTarget::Origin);
4108 }
4109 let viewer = self.viewer_login().ok()?;
4110 own_fork_target(pr, viewer, &self.origin_push_url())
4111 }
4112
4113 fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4114 const TRIES: u32 = 30;
4115 let mut tries = 1;
4116 loop {
4117 match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4118 Ok(_) => return Ok(()),
4119 Err(e) if tries >= TRIES => bail!(
4120 "the fork {} could not be reached after {} seconds. {}",
4121 fork.full_name,
4122 TRIES * 2,
4123 e.last_line()
4124 ),
4125 Err(_) => {
4126 tries += 1;
4127 std::thread::sleep(std::time::Duration::from_secs(2));
4128 }
4129 }
4130 }
4131 }
4132
4133 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4141 let head = self.head_oid_checked(cwd)?;
4142 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4143 return Ok(());
4144 }
4145 bail!(
4146 "refusing to push {head} because it does not contain the reviewed head {expected}. \
4147 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4148 the intended commits on top of it before resuming.",
4149 cwd.display()
4150 )
4151 }
4152
4153 pub fn push_split_branch(
4162 &self,
4163 cwd: &Path,
4164 branch: &str,
4165 ) -> std::result::Result<(), SplitPushError> {
4166 let remote_ref = format!("refs/heads/{branch}");
4167 let lease = format!("--force-with-lease={remote_ref}:");
4168 let refspec = format!("HEAD:{remote_ref}");
4169 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4170 Ok(_) => Ok(()),
4171 Err(push_error) => {
4172 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4173 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4174 reconcile_failed_split_push(branch, push_error, local, remote)
4175 }
4176 };
4177 self.record_write(result)
4178 }
4179
4180 pub fn gh(&self, args: &[&str]) -> Result<String> {
4183 self.gh_at(None, args)
4184 }
4185
4186 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4187 let mut argv = vec!["gh".to_string()];
4188 argv.extend(args.iter().map(|s| s.to_string()));
4189 proc::run(
4190 &argv,
4191 &ExecOpts::new()
4192 .cwd(cwd.unwrap_or(&self.root))
4193 .timeout_secs(300),
4194 )
4195 }
4196
4197 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4203 let mut argv = vec!["gh".to_string()];
4204 argv.extend(args.iter().map(|s| s.to_string()));
4205 proc::run(
4206 &argv,
4207 &ExecOpts::new()
4208 .cwd(&self.root)
4209 .timeout_secs(300)
4210 .stdin(stdin),
4211 )
4212 }
4213
4214 pub fn gh_try(&self, args: &[&str]) -> String {
4215 let mut argv = vec!["gh".to_string()];
4216 argv.extend(args.iter().map(|s| s.to_string()));
4217 proc::run(
4218 &argv,
4219 &ExecOpts::new()
4220 .cwd(&self.root)
4221 .check(false)
4222 .timeout_secs(300),
4223 )
4224 .unwrap_or_default()
4225 }
4226
4227 pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4232 let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4233 for (name, value) in headers {
4234 argv.push("-H".to_string());
4235 argv.push(format!("{name}: {value}"));
4236 }
4237 argv.push(endpoint.to_string());
4238 let out = proc::exec(
4239 &argv,
4240 &ExecOpts::new()
4241 .cwd(&self.root)
4242 .check(false)
4243 .timeout_secs(300),
4244 )?;
4245 parse_api_response(&out.stdout)
4246 .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4247 }
4248
4249 pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4251 let text = self.gh(&[
4252 "pr",
4253 "list",
4254 "--state",
4255 "open",
4256 "--limit",
4257 "500",
4258 "--json",
4259 PR_SWEEP_FIELDS,
4260 ])?;
4261 serde_json::from_str(&text)
4262 .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4263 }
4264
4265 pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4267 let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4268 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4269 }
4270
4271 pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4276 let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4278 if slug.is_empty() || login.is_empty() {
4279 return None;
4280 }
4281 let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4282 let resp = self.gh_api_raw(&path, &[]).ok()?;
4283 match resp.status {
4284 200 => {
4285 let body: Value = serde_json::from_str(&resp.body).ok()?;
4286 Some(body.get("state")?.as_str()? == "active")
4287 }
4288 404 => Some(false),
4289 _ => None,
4290 }
4291 }
4292
4293 pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4300 let text = self
4301 .gh(&[
4302 "api",
4303 "graphql",
4304 "-F",
4305 "owner={owner}",
4306 "-F",
4307 "repo={repo}",
4308 "-F",
4309 &format!("number={number}"),
4310 "-f",
4311 &format!("query={REQUESTS_QUERY}"),
4312 ])
4313 .ok()?;
4314 parse_review_requests(&text)
4315 }
4316
4317 pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4319 if from.is_empty() || to.is_empty() {
4320 return None;
4321 }
4322 let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4323 self.gh_try(&["api", &path, "--jq", ".total_commits"])
4324 .trim()
4325 .parse()
4326 .ok()
4327 }
4328
4329 pub fn repo_name(&self) -> Option<String> {
4331 let name = self.gh_try(&[
4332 "repo",
4333 "view",
4334 "--json",
4335 "nameWithOwner",
4336 "--jq",
4337 ".nameWithOwner",
4338 ]);
4339 let name = name.trim();
4340 (!name.is_empty()).then(|| name.to_string())
4341 }
4342
4343 pub fn listen_state_path(&self) -> PathBuf {
4344 self.root.join(STATE_DIR).join("state").join("listen.json")
4345 }
4346
4347 pub fn listen_lock_path(&self) -> PathBuf {
4348 self.root.join(STATE_DIR).join("state").join("listen.lock")
4349 }
4350
4351 pub fn viewer_login(&self) -> Result<&str> {
4362 if let Some(login) = self.viewer.get() {
4363 return Ok(login);
4364 }
4365 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4366 let login = if !rest.trim().is_empty() {
4367 rest.trim().to_string()
4368 } else {
4369 self.gh(&[
4372 "api",
4373 "graphql",
4374 "-f",
4375 "query={ viewer { login } }",
4376 "--jq",
4377 ".data.viewer.login",
4378 ])
4379 .map_err(|e| {
4380 spar_err!(
4381 "could not find out who `gh` is authenticated as, so spar cannot tell its \
4382 own comments from anybody else's. {}\nRun `gh auth status`.",
4383 e.last_line()
4384 )
4385 })?
4386 .trim()
4387 .to_string()
4388 };
4389 if login.is_empty() {
4390 bail!("`gh` reported an empty login. Run `gh auth status`.");
4391 }
4392 Ok(self.viewer.get_or_init(|| login))
4393 }
4394
4395 pub fn read_issue(&self, number: i64) -> Result<Issue> {
4401 let text = self
4402 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4403 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4404 serde_json::from_str(&text)
4405 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4406 }
4407
4408 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4409 let mut issues = Vec::new();
4410 for number in numbers {
4411 let text = self
4412 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4413 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4414 let issue: Issue = serde_json::from_str(&text)
4415 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4416 if issue.is_closed() {
4417 crate::log!("issue #{number} is closed, skipping");
4418 continue;
4419 }
4420 issues.push(issue);
4421 }
4422 if issues.is_empty() {
4423 bail!("no open issues to work on");
4424 }
4425 Ok(issues)
4426 }
4427
4428 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4434 #[derive(Deserialize)]
4435 struct Row {
4436 number: i64,
4437 }
4438 let text = self.gh(&[
4439 kind,
4440 "list",
4441 "--state",
4442 "open",
4443 "--limit",
4444 &FETCH_CEILING.to_string(),
4445 "--json",
4446 "number",
4447 ])?;
4448 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4449 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4450 numbers.sort_unstable();
4451
4452 let noun = if kind == "issue" { "issues" } else { "PRs" };
4453 let found = numbers.len();
4454 if min_number > 0 {
4455 numbers.retain(|n| *n >= min_number);
4456 let skipped = found - numbers.len();
4457 if skipped > 0 {
4458 crate::log!("{skipped} open {noun} below #{min_number} skipped");
4459 }
4460 }
4461 if found >= FETCH_CEILING {
4462 crate::log!(
4463 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4464 considered."
4465 );
4466 }
4467 if numbers.len() > limit {
4468 crate::log!(
4469 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4470 explicitly.",
4471 numbers.len()
4472 );
4473 numbers.truncate(limit);
4474 }
4475 Ok(numbers)
4476 }
4477
4478 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4480 self.open_numbers("issue", limit, min_number)
4481 }
4482
4483 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4484 self.open_numbers("pr", limit, min_number)
4485 }
4486
4487 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4488 self.branch_prs(branch, "open").into_iter().next()
4489 }
4490
4491 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4494 let text = self.gh(&[
4495 "pr",
4496 "list",
4497 "--head",
4498 branch,
4499 "--base",
4500 base,
4501 "--state",
4502 "open",
4503 "--json",
4504 "number,url,title,baseRefName",
4505 ])?;
4506 pr_for_base(&text, branch, base)
4507 }
4508
4509 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4513 self.branch_prs(branch, "all")
4514 }
4515
4516 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4517 let text = self.gh_try(&[
4518 "pr",
4519 "list",
4520 "--head",
4521 branch,
4522 "--state",
4523 state,
4524 "--json",
4525 "number,url,title",
4526 ]);
4527 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4528 }
4529
4530 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4536 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4537 let text = self
4538 .gh(&[
4539 "api",
4540 &path,
4541 "--jq",
4542 "if .pull_request then \"pr\" else \"issue\" end",
4543 ])
4544 .map_err(|e| {
4545 spar_err!(
4546 "no issue or pull request #{number} in this repository. {}",
4547 e.last_line()
4548 )
4549 })?;
4550 match text.trim() {
4551 "pr" => Ok(ItemKind::Pr),
4552 "issue" => Ok(ItemKind::Issue),
4553 other => Err(spar_err!(
4554 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4555 )),
4556 }
4557 }
4558
4559 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4570 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4571 if let Some(pr) = mine {
4572 return Ok(Some(pr));
4573 }
4574 let listed = match self.gh(&[
4575 "pr",
4576 "list",
4577 "--state",
4578 "open",
4579 "--limit",
4580 &FETCH_CEILING.to_string(),
4581 "--json",
4582 "number,url,title,baseRefName,closingIssuesReferences",
4583 ]) {
4584 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4585 spar_err!("could not read the pull request list while looking for #{issue}")
4586 })?,
4587 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4588 };
4589 if listed.len() >= FETCH_CEILING {
4590 logwarn!(
4591 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
4592 );
4593 }
4594 let default = self.default_branch(base);
4595 if !base.eq_ignore_ascii_case(&default) {
4596 logwarn!(
4600 "#{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."
4601 );
4602 }
4603 Ok(pick_linked_pr(&listed, issue))
4604 }
4605
4606 fn pr_for_branch_failing_closed(
4608 &self,
4609 branch: &str,
4610 base: &str,
4611 issue: i64,
4612 ) -> Result<Option<PrRef>> {
4613 match self.try_pr_for_branch(branch, base) {
4614 Ok(found) => Ok(found),
4615 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4616 }
4617 }
4618
4619 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4628 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4629 return Err(spar_err!(
4630 "could not check whether a pull request already exists for #{issue}: {}. \
4631 Refusing to implement over the top of work that may already be open.",
4632 e.last_line()
4633 ));
4634 }
4635 logwarn!(
4636 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4637 whether a pull request already exists for it.",
4638 e.last_line()
4639 );
4640 Ok(())
4641 }
4642
4643 pub fn pr_view(&self, number: i64) -> Result<PrView> {
4644 let text = self.gh(&[
4645 "pr",
4646 "view",
4647 &number.to_string(),
4648 "--json",
4649 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
4650 isCrossRepository,headRepositoryOwner,headRepository",
4651 ])?;
4652 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4653 }
4654
4655 fn try_pr_state(&self, number: i64) -> Result<String> {
4656 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4657 serde_json::from_str::<Value>(text.trim())
4658 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4659 .get("state")
4660 .and_then(Value::as_str)
4661 .map(str::to_string)
4662 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4663 }
4664
4665 pub fn pr_state(&self, number: i64) -> String {
4666 self.try_pr_state(number).unwrap_or_default()
4667 }
4668
4669 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
4671 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
4672 let oid = serde_json::from_str::<Value>(&text)
4673 .ok()
4674 .and_then(|value| {
4675 value
4676 .get("headRefOid")
4677 .and_then(Value::as_str)
4678 .map(str::trim)
4679 .filter(|oid| !oid.is_empty())
4680 .map(str::to_string)
4681 })
4682 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
4683 Ok(oid)
4684 }
4685
4686 pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
4690 let pull = self.pr_head_oid(pr.number)?;
4691 let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
4692 None
4693 } else {
4694 let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
4695 let listed = self
4696 .git(&["ls-remote", "--heads", "origin", &remote_ref])
4697 .map_err(|e| {
4698 spar_err!(
4699 "could not read where {} is on origin: {}",
4700 pr.head_ref_name.trim(),
4701 e.last_line()
4702 )
4703 })?;
4704 remote_head_oid(&listed, &remote_ref)?
4705 };
4706 Ok(LiveHead { pull, branch })
4707 }
4708
4709 pub fn create_pr(
4714 &self,
4715 cwd: &Path,
4716 branch: &str,
4717 target: &PushTarget,
4718 base: &str,
4719 title: &str,
4720 body: &str,
4721 ) -> Result<PrRef> {
4722 let title = self.record_failed_write(self.clean_title(title))?;
4723 let body = self.record_failed_write(self.clean(body))?;
4724 let head = target.head(branch);
4725 let mut argv = vec![
4726 "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
4727 ];
4728 if self.drafts != Drafts::Never {
4729 argv.push("--draft");
4730 }
4731 let created = self.gh_at(Some(cwd), &argv);
4732 let found = self.try_pr_for_branch(branch, base);
4733 self.record_write(reconcile_pr_creation(branch, created, found))
4734 }
4735
4736 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
4737 let body = signed(&self.record_failed_write(self.clean(body))?);
4738 let comments = self.record_failed_write(self.try_issue_comments(number))?;
4739 if has_exact_comment(&comments, &body) {
4740 return Ok(());
4741 }
4742 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
4743 let result = match posted {
4744 Ok(_) => Ok(()),
4745 Err(post_error) => {
4746 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4747 }
4748 };
4749 self.record_write(result)
4750 }
4751
4752 fn pr_is_own(&self, number: i64) -> Result<bool> {
4756 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
4757 let author = serde_json::from_str::<Value>(&text)
4758 .ok()
4759 .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
4760 .unwrap_or_default();
4761 let viewer = self.viewer_login()?.to_string();
4762 Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
4763 }
4764
4765 pub fn submit_review(
4778 &self,
4779 number: i64,
4780 verdict: Option<crate::model::ReviewVerdict>,
4781 body: &str,
4782 commit: &str,
4783 ) -> Result<()> {
4784 let own = match verdict {
4785 Some(_) => self.record_failed_write(self.pr_is_own(number))?,
4786 None => false,
4787 };
4788 let text = match (verdict, own) {
4789 (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
4790 _ => body.to_string(),
4791 };
4792 let body = signed(&self.record_failed_write(self.clean(&text))?);
4793 let viewer = self.record_failed_write(self.viewer_login())?.to_string();
4794 let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
4795 if has_viewer_review(&reviews, &viewer, &body, commit) {
4796 return Ok(());
4797 }
4798 let event = match (verdict, own) {
4799 (Some(v), false) => v.api_event(),
4800 _ => "COMMENT",
4801 };
4802 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
4803 let posted = self.gh(&[
4804 "api",
4805 "-X",
4806 "POST",
4807 &path,
4808 "-f",
4809 &format!("commit_id={commit}"),
4810 "-f",
4811 &format!("event={event}"),
4812 "-f",
4813 &format!("body={body}"),
4814 "--silent",
4815 ]);
4816 let result = match posted {
4817 Ok(_) => Ok(()),
4818 Err(post_error) => reconcile_review_post(
4819 number,
4820 &body,
4821 &viewer,
4822 commit,
4823 post_error,
4824 self.try_pr_reviews(number),
4825 ),
4826 };
4827 self.record_write(result)
4828 }
4829
4830 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
4831 let body = signed(&self.record_failed_write(self.clean(body))?);
4832 let comments = self.record_failed_write(self.try_issue_comments(number))?;
4833 if has_exact_comment(&comments, &body) {
4834 return Ok(());
4835 }
4836 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
4837 let result = match posted {
4838 Ok(_) => Ok(()),
4839 Err(post_error) => {
4840 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4841 }
4842 };
4843 self.record_write(result)
4844 }
4845
4846 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
4851 self.comment_issue(number, body)?;
4852 let n = number.to_string();
4853 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
4854 Ok(_) => Ok(()),
4855 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
4857 spar_err!(
4858 "commented on #{number} but could not close it: {}",
4859 e.last_line()
4860 )
4861 }),
4862 };
4863 self.record_write(closed)
4864 }
4865
4866 pub fn edit_issue_body(
4881 &self,
4882 number: i64,
4883 expected: &str,
4884 body: &str,
4885 inserted: &str,
4886 ) -> Result<()> {
4887 let cleaned = self.record_failed_write(self.clean(inserted))?;
4888 if cleaned.trim() != inserted.trim() {
4889 return self.record_failed_write(Err(spar_err!(
4890 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
4891 )));
4892 }
4893 let current = self.record_failed_write(self.issue_body(number))?;
4894 if current != expected {
4895 return self.record_failed_write(Err(spar_err!(
4896 "the body of #{number} changed since it was read, so it was left alone rather \
4897 than written over."
4898 )));
4899 }
4900 let edited = self.gh_stdin(
4901 &["issue", "edit", &number.to_string(), "--body-file", "-"],
4902 body,
4903 );
4904 let result = match edited {
4905 Ok(_) => Ok(()),
4906 Err(edit_error) => {
4907 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
4908 }
4909 };
4910 self.record_write(result)
4911 }
4912
4913 pub fn issue_body(&self, number: i64) -> Result<String> {
4915 #[derive(Deserialize)]
4916 struct Row {
4917 #[serde(default)]
4918 body: Option<String>,
4919 }
4920 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
4921 let row: Row = serde_json::from_str(text.trim())
4922 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4923 Ok(row.body.unwrap_or_default())
4924 }
4925
4926 pub fn open_issue_rows(&self) -> Vec<Issue> {
4931 let text = self.gh_try(&[
4932 "issue",
4933 "list",
4934 "--state",
4935 "open",
4936 "--limit",
4937 &FETCH_CEILING.to_string(),
4938 "--json",
4939 "number,title,body,labels,state,stateReason,url",
4940 ]);
4941 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
4942 }
4943
4944 pub fn open_pr_rows(&self) -> Vec<PrRow> {
4946 let text = self.gh_try(&[
4947 "pr",
4948 "list",
4949 "--state",
4950 "open",
4951 "--limit",
4952 &FETCH_CEILING.to_string(),
4953 "--json",
4954 "number,title,changedFiles,additions,deletions",
4955 ]);
4956 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
4957 }
4958
4959 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
4960 self.create_issue_apart_from(title, body, None)
4961 }
4962
4963 pub fn create_issue_apart_from(
4964 &self,
4965 title: &str,
4966 body: &str,
4967 apart_from: Option<i64>,
4968 ) -> Result<String> {
4969 let title = self.record_failed_write(self.clean_title(title))?;
4970 let body = self.record_failed_write(self.clean_issue_body(body))?;
4971 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
4972 let result = match created {
4973 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
4974 created => {
4975 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
4976 reconcile_issue_creation(&title, created, found)
4977 }
4978 };
4979 self.record_write(result)
4980 }
4981}
4982
4983#[derive(Debug, Clone)]
4985pub struct ExistingIssue {
4986 pub number: i64,
4987 pub url: String,
4988 pub title: String,
4989 pub body: String,
4990 pub open: bool,
4991}
4992
4993impl Repo {
4994 pub(crate) fn try_exact_issue_apart_from(
4995 &self,
4996 title: &str,
4997 body: &str,
4998 apart_from: Option<i64>,
4999 ) -> Result<Option<ExistingIssue>> {
5000 #[derive(Deserialize)]
5001 #[serde(rename_all = "camelCase")]
5002 struct Row {
5003 number: i64,
5004 #[serde(default)]
5005 title: String,
5006 #[serde(default)]
5007 url: String,
5008 #[serde(default)]
5009 body: Option<String>,
5010 #[serde(default)]
5011 state: String,
5012 }
5013
5014 let text = self.gh(&[
5015 "issue",
5016 "list",
5017 "--state",
5018 "all",
5019 "--limit",
5020 "100",
5021 "--json",
5022 "number,title,url,body,state,stateReason",
5023 ])?;
5024 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5025 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5026 Ok(rows
5027 .into_iter()
5028 .filter(|row| Some(row.number) != apart_from)
5029 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5030 .map(|row| ExistingIssue {
5031 number: row.number,
5032 url: row.url,
5033 title: row.title,
5034 body: row.body.unwrap_or_default(),
5035 open: row.state.eq_ignore_ascii_case("open"),
5036 }))
5037 }
5038
5039 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5047 self.find_similar_issue_apart_from(title, body, None)
5048 }
5049
5050 pub fn find_similar_issue_apart_from(
5058 &self,
5059 title: &str,
5060 body: &str,
5061 apart_from: Option<i64>,
5062 ) -> Option<ExistingIssue> {
5063 self.try_similar_issue(title, body, apart_from, true)
5064 .ok()
5065 .flatten()
5066 }
5067
5068 pub fn try_find_similar_issue_apart_from(
5070 &self,
5071 title: &str,
5072 body: &str,
5073 apart_from: Option<i64>,
5074 ) -> Result<Option<ExistingIssue>> {
5075 self.try_similar_issue(title, body, apart_from, false)
5076 }
5077
5078 fn try_similar_issue(
5080 &self,
5081 title: &str,
5082 body: &str,
5083 apart_from: Option<i64>,
5084 include_closed: bool,
5085 ) -> Result<Option<ExistingIssue>> {
5086 #[derive(Deserialize)]
5087 #[serde(rename_all = "camelCase")]
5088 struct Row {
5089 number: i64,
5090 #[serde(default)]
5091 title: String,
5092 #[serde(default)]
5093 url: String,
5094 #[serde(default)]
5095 body: String,
5096 #[serde(default)]
5097 state: String,
5098 }
5099 if title.trim().is_empty() {
5100 return Ok(None);
5101 }
5102 let query: String = title
5105 .chars()
5106 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5107 .take(120)
5108 .collect();
5109 let state = if include_closed { "all" } else { "open" };
5118 let text = self.gh(&[
5119 "issue",
5120 "list",
5121 "--state",
5122 state,
5123 "--limit",
5124 "100",
5125 "--search",
5126 query.trim(),
5127 "--json",
5128 "number,title,url,body,state,stateReason",
5129 ])?;
5130 let rows: Vec<Row> = serde_json::from_str(text.trim())
5131 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5132
5133 Ok(rows
5134 .into_iter()
5135 .filter(|row| Some(row.number) != apart_from)
5136 .find(|row| {
5137 row.title.trim().eq_ignore_ascii_case(title.trim())
5138 || textsim::covers(title, body, &row.title, &row.body)
5139 })
5140 .map(|row| ExistingIssue {
5141 number: row.number,
5142 url: row.url,
5143 title: row.title,
5144 open: row.state.eq_ignore_ascii_case("open"),
5145 body: row.body,
5146 }))
5147 }
5148
5149 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5151 #[derive(Deserialize)]
5152 struct Row {
5153 title: String,
5154 url: String,
5155 }
5156 let needle = title.trim().to_lowercase();
5157 if needle.is_empty() {
5158 return None;
5159 }
5160 let query: String = title
5162 .chars()
5163 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5164 .take(120)
5165 .collect();
5166 let text = self.gh_try(&[
5167 "issue",
5168 "list",
5169 "--state",
5170 "all",
5171 "--limit",
5172 "100",
5173 "--search",
5174 query.trim(),
5175 "--json",
5176 "number,title,url",
5177 ]);
5178 serde_json::from_str::<Vec<Row>>(text.trim())
5179 .ok()?
5180 .into_iter()
5181 .find(|row| row.title.trim().to_lowercase() == needle)
5182 .map(|row| row.url)
5183 }
5184
5185 pub fn mark_ready(&self, number: i64) -> bool {
5193 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5194 Ok(_) => true,
5195 Err(e) => {
5196 logdim!(
5197 "PR #{number} is approved but could not be taken out of draft: {}",
5198 e.last_line()
5199 );
5200 false
5201 }
5202 }
5203 }
5204
5205 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5207 let listed = self
5208 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5209 .ok()?;
5210 let wanted = format!("branch refs/heads/{branch}");
5211 let mut current: Option<PathBuf> = None;
5212 for line in listed.lines() {
5213 if let Some(path) = line.strip_prefix("worktree ") {
5214 current = Some(PathBuf::from(path));
5215 } else if line == wanted {
5216 return current;
5217 }
5218 }
5219 None
5220 }
5221
5222 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5238 let branch = self.pr_view(number).ok()?.head_ref_name;
5239 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5240 return None;
5241 }
5242 let mut released = None;
5243 if let Some(path) = self.worktree_holding(&branch) {
5244 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5245 return None;
5246 }
5247 if !matches!(
5248 self.has_recoverable_work(&path, Disposable::BuildOutput),
5249 Ok(false)
5250 ) {
5251 return None;
5252 }
5253 match self.remove_worktree_at(&path) {
5254 Ok(true) => released = Some(path),
5255 _ => return None,
5256 }
5257 }
5258 match self.delete_branch_if_safe(&branch) {
5259 Ok(true) => {
5260 self.forget_branch(&branch);
5261 Some(match released {
5262 Some(path) => format!(
5263 " The worktree at {} was released and branch {branch} deleted.",
5264 path.display()
5265 ),
5266 None => format!(" Branch {branch} was deleted."),
5267 })
5268 }
5269 _ => released.map(|path| {
5270 format!(
5271 " The worktree at {} was released, but branch {branch} was kept.",
5272 path.display()
5273 )
5274 }),
5275 }
5276 }
5277
5278 pub fn merge_pr(&self, number: i64) -> Result<()> {
5282 let n = number.to_string();
5283 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5284 Ok(_) => Ok(()),
5285 Err(e) => {
5286 if self.pr_state(number) == "MERGED" {
5287 let finished = self
5288 .finish_merged_branch_cleanup(number)
5289 .unwrap_or_default();
5290 logdim!(
5291 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5292 e.last_line()
5293 );
5294 Ok(())
5295 } else {
5296 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5297 }
5298 }
5299 };
5300 self.record_write(merged)
5301 }
5302
5303 pub fn merge_pr_at_head(
5305 &self,
5306 number: i64,
5307 expected_head: &str,
5308 delete_branch: bool,
5309 ) -> Result<()> {
5310 let n = number.to_string();
5311 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5312 Ok(_) => Ok(()),
5313 Err(e) => {
5314 if self.pr_state(number) == "MERGED" {
5315 let finished = self
5316 .finish_merged_branch_cleanup(number)
5317 .unwrap_or_default();
5318 logdim!(
5319 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5320 e.last_line()
5321 );
5322 Ok(())
5323 } else {
5324 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5325 }
5326 }
5327 };
5328 self.record_write(merged)
5329 }
5330
5331 pub fn followups_path(&self) -> PathBuf {
5336 self.root.join(STATE_DIR).join("followups.md")
5337 }
5338
5339 pub fn brainstorms_dir(&self) -> PathBuf {
5344 self.root.join(STATE_DIR).join("brainstorms")
5345 }
5346
5347 pub fn worked_followups_path(&self) -> PathBuf {
5355 self.root.join(STATE_DIR).join("followups.done.md")
5356 }
5357
5358 pub fn spend_path(&self) -> PathBuf {
5361 self.root.join(STATE_DIR).join("state").join("spend.json")
5362 }
5363
5364 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5370 if calls.is_empty() {
5371 return Ok(());
5372 }
5373 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5374 .ok()
5375 .and_then(|text| serde_json::from_str(&text).ok())
5376 .unwrap_or_default();
5377 all.extend(calls.iter().cloned());
5378 if let Some(parent) = self.spend_path().parent() {
5379 let _ = std::fs::create_dir_all(parent);
5380 }
5381 write_json_atomic(&self.spend_path(), &all)
5382 }
5383
5384 pub fn implementors_path(&self) -> PathBuf {
5390 self.root
5391 .join(STATE_DIR)
5392 .join("state")
5393 .join("implementors.json")
5394 }
5395
5396 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5397 std::fs::read_to_string(self.implementors_path())
5398 .ok()
5399 .and_then(|text| serde_json::from_str(&text).ok())
5400 .unwrap_or_default()
5401 }
5402
5403 pub fn remember_implementor(&self, issue: i64, agent: &str) {
5406 let mut known = self.read_implementors();
5407 known.insert(issue, agent.to_string());
5408 if let Some(parent) = self.implementors_path().parent() {
5409 let _ = std::fs::create_dir_all(parent);
5410 }
5411 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5412 logdim!("could not record who implemented #{issue}: {e}");
5413 }
5414 }
5415
5416 pub fn contested_path(&self) -> PathBuf {
5421 self.root
5422 .join(STATE_DIR)
5423 .join("state")
5424 .join("contested.json")
5425 }
5426
5427 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5429 std::fs::read_to_string(self.contested_path())
5430 .ok()
5431 .and_then(|text| serde_json::from_str(&text).ok())
5432 .unwrap_or_default()
5433 }
5434
5435 pub fn remember_contested(&self, items: &[ContestedItem]) {
5440 if items.is_empty() {
5441 return;
5442 }
5443 let mut known = self.read_contested();
5444 for item in items {
5445 known.insert(
5446 item.issue,
5447 Contested {
5448 title: item.title.clone(),
5449 positions: item.positions.clone(),
5450 reasons: item.reasons.clone(),
5451 },
5452 );
5453 }
5454 if let Some(parent) = self.contested_path().parent() {
5455 let _ = std::fs::create_dir_all(parent);
5456 }
5457 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5458 logdim!("could not record the contested issues: {e}");
5459 }
5460 }
5461
5462 pub fn forget_contested(&self, numbers: &[i64]) {
5464 let mut known = self.read_contested();
5465 let before = known.len();
5466 known.retain(|number, _| !numbers.contains(number));
5467 if known.len() == before {
5468 return;
5469 }
5470 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5471 logdim!("could not update the contested issues: {e}");
5472 }
5473 }
5474
5475 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5476 self.root
5477 .join(STATE_DIR)
5478 .join("state")
5479 .join(format!("checkin-{number}.json"))
5480 }
5481
5482 pub fn review_record_path(&self, number: i64) -> PathBuf {
5484 self.root
5485 .join(STATE_DIR)
5486 .join("state")
5487 .join(format!("review-{number}.json"))
5488 }
5489
5490 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5493 let path = self.review_record_path(number);
5494 let text = std::fs::read_to_string(&path).ok()?;
5495 match serde_json::from_str(&text) {
5496 Ok(record) => Some(record),
5497 Err(e) => {
5498 logdim!("could not read {}: {e}", path.display());
5499 None
5500 }
5501 }
5502 }
5503
5504 pub fn write_review_record(
5505 &self,
5506 number: i64,
5507 record: &crate::model::ReviewRecord,
5508 ) -> Result<()> {
5509 write_json_atomic(&self.review_record_path(number), record)
5510 }
5511
5512 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5527 let path = self.followups_path();
5528 let heading = format!("## {}", title.trim());
5529 for seen in [&path, &self.worked_followups_path()] {
5530 if let Ok(existing) = std::fs::read_to_string(seen) {
5531 if has_heading(&existing, title) {
5532 logdim!("follow-up already noted: {title}");
5533 return Followup::Covered(format!("note: {}", title.trim()));
5534 }
5535 }
5536 }
5537 if let Some(parent) = path.parent() {
5538 let _ = std::fs::create_dir_all(parent);
5539 }
5540 use std::io::Write;
5541 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5548 match std::fs::OpenOptions::new()
5549 .create(true)
5550 .append(true)
5551 .open(&path)
5552 {
5553 Ok(mut file) => match file.write_all(entry.as_bytes()) {
5554 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5555 Err(e) => {
5556 logdim!("could not write {}: {e}", path.display());
5557 Followup::Failed
5558 }
5559 },
5560 Err(e) => {
5561 logdim!("could not write {}: {e}", path.display());
5562 Followup::Failed
5563 }
5564 }
5565 }
5566
5567 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5572 let path = self.worked_followups_path();
5573 if let Some(parent) = path.parent() {
5574 let _ = std::fs::create_dir_all(parent);
5575 }
5576 use std::io::Write;
5577 let entry = format!(
5578 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5579 title.trim(),
5580 body.trim()
5581 );
5582 if let Ok(mut file) = std::fs::OpenOptions::new()
5583 .create(true)
5584 .append(true)
5585 .open(&path)
5586 {
5587 let _ = file.write_all(entry.as_bytes());
5588 }
5589 }
5590
5591 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5600 self.root
5601 .join(STATE_DIR)
5602 .join("reviews")
5603 .join(format!("pr-{number}.md"))
5604 }
5605
5606 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5612 let path = self.pending_comment_path(number);
5613 if let Some(parent) = path.parent() {
5614 std::fs::create_dir_all(parent)
5615 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5616 }
5617 std::fs::write(&path, text)
5618 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5619 Ok(path)
5620 }
5621
5622 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5623 std::fs::read_to_string(self.pending_comment_path(number)).ok()
5624 }
5625
5626 pub fn state_path(&self, number: i64) -> PathBuf {
5627 self.root
5628 .join(STATE_DIR)
5629 .join("state")
5630 .join(format!("pr-{number}.json"))
5631 }
5632
5633 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5634 let path = self.state_path(number);
5635 let text = std::fs::read_to_string(&path).ok()?;
5636 match serde_json::from_str(&text) {
5637 Ok(state) => Some(state),
5638 Err(_) => {
5639 logdim!("could not read {}, starting fresh", path.display());
5640 None
5641 }
5642 }
5643 }
5644
5645 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5646 if let Some(local) = self.read_local_state(pr.number) {
5647 return Some(local);
5648 }
5649 if self.state_store.writes_pr() {
5650 return self.read_pr_state(pr.number);
5651 }
5652 None
5653 }
5654
5655 pub(crate) fn read_state_for_head(
5656 &self,
5657 pr: &PrView,
5658 actual_head: &str,
5659 ) -> Option<PersistedState> {
5660 let local = self
5661 .state_store
5662 .writes_local()
5663 .then(|| self.read_local_state(pr.number))
5664 .flatten();
5665 let remote = self
5666 .state_store
5667 .writes_pr()
5668 .then(|| self.read_pr_state(pr.number))
5669 .flatten();
5670 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5671 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5672 self.remember_checkpoint(pr.number, checkpoint);
5673 }
5674 choose_state_for_head(candidates, actual_head)
5675 }
5676
5677 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5678 self.try_read_pr_state(number).ok().flatten()
5679 }
5680
5681 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5682 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5683 if let Some(state) = parse_state_comment(&body) {
5684 return Ok(Some(state));
5685 }
5686 }
5687 Ok(None)
5688 }
5689
5690 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5691 let remote_state = if self.state_store.writes_pr() {
5692 self.try_read_pr_state(number)
5693 } else {
5694 Ok(None)
5695 };
5696 self.write_state_after_remote_read(number, state, remote_state)
5697 }
5698
5699 fn write_state_after_remote_read(
5700 &self,
5701 number: i64,
5702 state: &PersistedState,
5703 remote_state: Result<Option<PersistedState>>,
5704 ) -> Result<()> {
5705 let remote_checkpoint = if self.state_store.writes_pr() {
5706 self.record_failed_write(remote_state)?
5707 .map(|saved| saved.checkpoint)
5708 .unwrap_or_default()
5709 } else {
5710 0
5711 };
5712 let local_checkpoint = self
5713 .state_store
5714 .writes_local()
5715 .then(|| self.read_local_state(number))
5716 .flatten()
5717 .map(|saved| saved.checkpoint)
5718 .unwrap_or_default();
5719 let mut stamped = state.clone();
5720 stamped.checkpoint = state
5721 .checkpoint
5722 .max(local_checkpoint)
5723 .max(remote_checkpoint)
5724 .max(self.remembered_checkpoint(number))
5725 .saturating_add(1);
5726 self.remember_checkpoint(number, stamped.checkpoint);
5727 if self.state_store.writes_local() {
5728 write_json_atomic(&self.state_path(number), &stamped)?;
5729 }
5730 if self.state_store.writes_pr() {
5731 self.write_pr_state(number, &stamped)?;
5732 }
5733 Ok(())
5734 }
5735
5736 fn remembered_checkpoint(&self, number: i64) -> u64 {
5737 self.checkpoints
5738 .lock()
5739 .unwrap_or_else(std::sync::PoisonError::into_inner)
5740 .get(&number)
5741 .copied()
5742 .unwrap_or_default()
5743 }
5744
5745 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
5746 let mut checkpoints = self
5747 .checkpoints
5748 .lock()
5749 .unwrap_or_else(std::sync::PoisonError::into_inner);
5750 let saved = checkpoints.entry(number).or_default();
5751 *saved = (*saved).max(checkpoint);
5752 }
5753
5754 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5755 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
5759 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
5760 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
5761 if let Some(id) = comment_id {
5762 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5763 let field = format!("body={body}");
5764 let written = self
5765 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
5766 .map(|_| ());
5767 return self.record_write(written);
5768 }
5769 let written = self
5770 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
5771 .map(|_| ());
5772 self.record_write(written)
5773 }
5774
5775 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
5783 self.try_issue_comments(number).unwrap_or_default()
5784 }
5785
5786 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
5787 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
5788 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
5789 }
5790
5791 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
5798 let viewer = self.viewer_login()?.to_string();
5799 let mut mine = Vec::new();
5800 let mut theirs = Vec::new();
5801 for comment in self.try_issue_comments(number)? {
5802 let Some(body) = comment.get("body").and_then(Value::as_str) else {
5803 continue;
5804 };
5805 if !body.contains("spar:state") {
5806 continue;
5807 }
5808 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
5809 continue;
5810 };
5811 let author = comment
5812 .get("user")
5813 .and_then(|u| u.get("login"))
5814 .and_then(Value::as_str)
5815 .unwrap_or_default();
5816 if is_state_comment(body, author, &viewer) {
5817 mine.push((id, body.to_string()));
5818 } else {
5819 theirs.push(format!("@{author}"));
5820 }
5821 }
5822 if !theirs.is_empty() {
5823 logdim!(
5824 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
5825 own state block is patched or deleted.",
5826 theirs.len(),
5827 theirs.join(", ")
5828 );
5829 }
5830 Ok(mine)
5831 }
5832
5833 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
5834 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
5835 }
5836
5837 pub fn clear_state(&self, number: i64) {
5839 let path = self.state_path(number);
5840 let _ = std::fs::remove_file(&path);
5841 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
5842 }
5843
5844 pub fn prune_state(&self) -> Vec<String> {
5851 let base = self.root.join(STATE_DIR).join("state");
5852 let Ok(entries) = std::fs::read_dir(&base) else {
5853 return Vec::new();
5854 };
5855 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
5856 for name in entries
5857 .flatten()
5858 .filter_map(|e| e.file_name().to_str().map(str::to_string))
5859 {
5860 if let Some(number) = state_file_number(&name) {
5861 by_number.entry(number).or_default().push(name);
5862 }
5863 }
5864
5865 let mut removed = Vec::new();
5866 for (number, mut names) in by_number {
5867 if !is_finished(&self.pr_state(number)) {
5868 continue;
5869 }
5870 names.sort();
5871 for name in names {
5872 let _ = std::fs::remove_file(base.join(&name));
5873 removed.push(format!("state {name}"));
5874 }
5875 }
5876 removed
5877 }
5878
5879 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
5883 #[derive(Deserialize)]
5884 struct Row {
5885 number: i64,
5886 }
5887 let numbers = match numbers {
5888 Some(numbers) => numbers,
5889 None => {
5890 let listed: Result<Vec<i64>> = (|| {
5891 let text = self.gh(&[
5892 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
5893 ])?;
5894 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5895 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
5896 Ok(rows.into_iter().map(|row| row.number).collect())
5897 })();
5898 match self.record_failed_write(listed) {
5899 Ok(numbers) => numbers,
5900 Err(e) => {
5901 logdim!("could not inspect pull requests for state cleanup: {e}");
5902 return Vec::new();
5903 }
5904 }
5905 }
5906 };
5907
5908 let mut removed = Vec::new();
5909 for number in numbers {
5910 let state = match self.record_failed_write(self.try_pr_state(number)) {
5911 Ok(state) => state,
5912 Err(e) => {
5913 logdim!("could not inspect PR #{number} for state cleanup: {e}");
5914 continue;
5915 }
5916 };
5917 if !is_finished(&state) {
5918 continue;
5919 }
5920 let comments = match self.record_failed_write(self.try_state_comments(number)) {
5921 Ok(comments) => comments,
5922 Err(e) => {
5923 logdim!("could not inspect state comments on PR #{number}: {e}");
5924 continue;
5925 }
5926 };
5927 for (id, _) in comments {
5928 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5929 let deleted = self
5930 .gh(&["api", "-X", "DELETE", &path, "--silent"])
5931 .map(|_| ());
5932 match self.record_write(deleted) {
5933 Ok(()) => removed.push(format!("state comment on PR #{number}")),
5934 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
5935 }
5936 }
5937 }
5938 removed
5939 }
5940
5941 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
5948 let base = self.root.join(WORKTREE_DIR);
5949 let mut removed = Vec::new();
5950 let known = self.known_branches();
5951
5952 if let Ok(entries) = std::fs::read_dir(&base) {
5953 let mut names: Vec<String> = entries
5954 .flatten()
5955 .filter(|e| e.path().is_dir())
5956 .filter_map(|e| e.file_name().to_str().map(str::to_string))
5957 .collect();
5958 names.sort();
5959
5960 for name in names {
5961 if let Some(rest) = name.strip_prefix("review-") {
5964 let number: i64 = rest.parse().unwrap_or(-1);
5965 if !(force_all || is_finished(&self.pr_state(number))) {
5966 continue;
5967 }
5968 let path = base.join(&name);
5969 if force_all {
5970 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
5971 if !belongs {
5972 return Ok(false);
5973 }
5974 let local_ref = review_ref(number);
5975 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
5976 return Ok(false);
5977 }
5978 let head = self.head_oid_checked(&path)?;
5979 let recorded = self
5980 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
5981 .trim()
5982 .to_string();
5983 Ok(head == recorded)
5984 });
5985 match owned {
5986 Ok(true) => {}
5987 Ok(false) => {
5988 logdim!(
5989 "kept {} because no matching SPAR review reference proves \
5990 ownership",
5991 path.display()
5992 );
5993 continue;
5994 }
5995 Err(e) => {
5996 logdim!(
5997 "kept {} because review ownership could not be verified: {}",
5998 path.display(),
5999 e.last_line()
6000 );
6001 continue;
6002 }
6003 }
6004 } else {
6005 if let Err(e) = self.refuse_review_worktree_changes(number) {
6006 logdim!(
6007 "kept {} because its review state could not be verified as \
6008 disposable: {}",
6009 path.display(),
6010 e.last_line()
6011 );
6012 continue;
6013 }
6014 }
6015 if force_all {
6016 if self.remove_worktree_at_force(&path) {
6017 self.git_try(&["update-ref", "-d", &review_ref(number)]);
6018 }
6019 } else {
6020 self.release_review_worktree(number);
6021 }
6022 if !path.exists() {
6023 removed.push(name);
6024 }
6025 continue;
6026 }
6027 let branch = format!("{}{name}", self.branch_prefix);
6028 if !(force_all || self.worktree_is_done(&branch)) {
6029 continue;
6030 }
6031 if !known.contains_key(&branch) {
6032 logdim!("kept {branch} because it has no branch record");
6033 continue;
6034 }
6035 let path = base.join(&name);
6036 if !force_all {
6037 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6038 Ok(true) => {
6039 logdim!(
6040 "kept {} because it contains uncommitted changes or ignored files",
6041 path.display()
6042 );
6043 continue;
6044 }
6045 Err(e) => {
6046 logdim!(
6047 "kept {} because its Git state could not be checked: {}",
6048 path.display(),
6049 e.last_line()
6050 );
6051 continue;
6052 }
6053 Ok(false) => {}
6054 }
6055 match self.branch_deletion_is_safe(&branch) {
6056 Ok(true) => {}
6057 Ok(false) => {
6058 logdim!(
6059 "kept {branch} because no surviving ref preserves its tip or \
6060 reflog-only commits"
6061 );
6062 continue;
6063 }
6064 Err(e) => {
6065 logdim!(
6066 "kept {branch} because preservation could not be verified: {}",
6067 e.last_line()
6068 );
6069 continue;
6070 }
6071 }
6072 }
6073 let removed_worktree = if force_all {
6074 self.remove_worktree_at_force(&path)
6075 } else {
6076 match self.remove_worktree_at(&path) {
6077 Ok(removed) => removed,
6078 Err(error) => {
6079 logdim!(
6080 "kept {branch} and {} because removal did not reach a confirmed \
6081 quiet point: {}",
6082 path.display(),
6083 error.last_line()
6084 );
6085 false
6086 }
6087 }
6088 };
6089 if !removed_worktree {
6090 continue;
6091 }
6092 if force_all {
6093 self.git_try(&["branch", "-D", &branch]);
6094 self.forget_branch(&branch);
6095 } else {
6096 match self.delete_branch_if_safe(&branch) {
6097 Ok(true) => self.forget_branch(&branch),
6098 Ok(false) => logdim!(
6099 "kept {branch} because its tip or reflog changed before deletion"
6100 ),
6101 Err(error) => logdim!(
6102 "kept {branch} because deletion safety could not be rechecked: {}",
6103 error.last_line()
6104 ),
6105 }
6106 }
6107 removed.push(name);
6108 }
6109 }
6110 removed.extend(self.prune_branches(force_all));
6111 removed
6112 }
6113
6114 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6121 let known = self.known_branches();
6122 let branches: Vec<String> = known.keys().cloned().collect();
6123 if branches.is_empty() {
6124 return Vec::new();
6125 }
6126
6127 let checked_out: Vec<String> = self
6128 .git_try(&["worktree", "list", "--porcelain"])
6129 .lines()
6130 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6131 .collect();
6132
6133 let existing: Vec<String> = self
6136 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6137 .lines()
6138 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6139 .collect();
6140
6141 let mut removed = Vec::new();
6142 for branch in branches {
6143 if !existing.contains(&branch) {
6144 self.forget_branch(&branch); continue;
6146 }
6147 if checked_out.contains(&branch) {
6148 continue;
6149 }
6150 if !(force_all || self.worktree_is_done(&branch)) {
6151 continue;
6152 }
6153 if !force_all {
6154 let Some(_record) = known.get(&branch) else {
6155 continue;
6156 };
6157 match self.branch_deletion_is_safe(&branch) {
6158 Ok(true) => {}
6159 Ok(false) => {
6160 logdim!(
6161 "kept {branch} because no surviving ref preserves its tip or \
6162 reflog-only commits"
6163 );
6164 continue;
6165 }
6166 Err(e) => {
6167 logdim!(
6168 "kept {branch} because preservation could not be verified: {}",
6169 e.last_line()
6170 );
6171 continue;
6172 }
6173 }
6174 }
6175 let deleted = if force_all {
6176 self.git(&["branch", "-D", &branch]).map(|_| true)
6177 } else {
6178 self.delete_branch_if_safe(&branch)
6179 };
6180 match deleted {
6181 Ok(true) => {
6182 self.forget_branch(&branch);
6183 removed.push(format!("branch {branch}"));
6184 }
6185 Ok(false) => {
6186 logdim!("kept {branch} because its tip or reflog changed before deletion");
6187 }
6188 Err(e) => {
6189 logdim!("could not delete {branch}: {}", e.last_line());
6192 }
6193 }
6194 }
6195 removed
6196 }
6197
6198 fn worktree_is_done(&self, branch: &str) -> bool {
6200 #[derive(Deserialize)]
6201 struct Row {
6202 state: String,
6203 }
6204 let entry = branch
6205 .strip_prefix(self.branch_prefix.as_str())
6206 .unwrap_or(branch);
6207 if let Some(rest) = entry.strip_prefix("pr-") {
6208 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6209 }
6210 if entry.starts_with("issue-") || entry.starts_with("split-") {
6214 let text = self.gh_try(&[
6215 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6216 ]);
6217 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6218 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6219 }
6220 false
6221 }
6222}
6223
6224pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6236 match probe() {
6237 Ok(value) => Ok(value),
6238 Err(first) => {
6239 logdim!("retrying a worktree probe after: {}", first.last_line());
6240 probe()
6241 }
6242 }
6243}
6244
6245pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6251 let root = std::fs::canonicalize(cwd)
6252 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6253 let mut files = BTreeMap::new();
6254 let mut visited = BTreeSet::new();
6255 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6256 Ok(AttributeState { files })
6257}
6258
6259fn collect_attribute_files(
6260 root: &Path,
6261 repository: &Path,
6262 prefix: &Path,
6263 visited: &mut BTreeSet<PathBuf>,
6264 files: &mut BTreeMap<PathBuf, [u8; 32]>,
6265) -> Result<()> {
6266 let canonical = std::fs::canonicalize(repository)
6267 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6268 if !visited.insert(canonical) {
6269 bail!("submodule recursion revisited {}", repository.display());
6270 }
6271 let entries = index_entries(repository)?;
6272 let mut paths: BTreeSet<PathBuf> = entries
6273 .iter()
6274 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6275 .map(|entry| entry.path.clone())
6276 .collect();
6277 paths.extend(untracked_attribute_paths(repository)?);
6283 for path in paths {
6284 let from_root = prefix.join(&path);
6285 let state = attribute_file_fingerprint(&root.join(&from_root))?;
6286 files.insert(from_root, state);
6287 }
6288 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6289 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6290 continue;
6291 };
6292 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6293 }
6294 Ok(())
6295}
6296
6297pub(crate) fn uncertain_worktree_change(
6300 cwd: &Path,
6301 message: impl Into<String>,
6302) -> crate::error::SparError {
6303 let message = message.into();
6304 let marker = write_recovery_marker(cwd, &message);
6305 let note = match marker {
6306 Ok(path) => format!(" Recovery marker: {}.", path.display()),
6307 Err(e) => format!(
6308 " A recovery marker could not be written: {}.",
6309 e.last_line()
6310 ),
6311 };
6312 crate::error::SparError::uncertain_write(format!("{message}{note}"))
6313}
6314
6315fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6316 use std::sync::atomic::{AtomicU32, Ordering};
6317 static NEXT: AtomicU32 = AtomicU32::new(0);
6318 for _ in 0..1000 {
6319 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6320 let path = cwd.join(format!(
6321 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6322 std::process::id()
6323 ));
6324 let mut options = OpenOptions::new();
6325 options.write(true).create_new(true);
6326 #[cfg(unix)]
6327 {
6328 use std::os::unix::fs::OpenOptionsExt;
6329 options.mode(0o600);
6330 }
6331 match options.open(&path) {
6332 Ok(mut file) => {
6333 file.write_all(detail.as_bytes())
6334 .and_then(|_| file.write_all(b"\n"))
6335 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6336 return Ok(path);
6337 }
6338 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6339 Err(e) => {
6340 return Err(spar_err!(
6341 "could not create a recovery marker in {}: {e}",
6342 cwd.display()
6343 ))
6344 }
6345 }
6346 }
6347 bail!(
6348 "could not choose a free recovery marker name in {}",
6349 cwd.display()
6350 )
6351}
6352
6353fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6358 let mut argv = vec![
6359 "git".to_string(),
6360 "-c".to_string(),
6361 "maintenance.auto=false".to_string(),
6362 "-c".to_string(),
6363 "gc.auto=0".to_string(),
6364 ];
6365 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6366 argv
6367}
6368
6369fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6370 let mut argv = git_without_maintenance_argv(&[]);
6371 argv.extend([
6372 "-c".to_string(),
6373 "core.fsmonitor=".to_string(),
6374 "-c".to_string(),
6375 "commit.gpgsign=false".to_string(),
6376 "-c".to_string(),
6377 "core.hooksPath=/dev/null".to_string(),
6378 ]);
6379 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6380 argv
6381}
6382
6383pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6390 let root = std::fs::canonicalize(cwd)
6391 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6392 let mut files = BTreeMap::new();
6393 let mut ignored = BTreeSet::new();
6394 let mut visited = BTreeSet::new();
6395 collect_untracked_files(
6396 &root,
6397 &root,
6398 Path::new(""),
6399 &mut visited,
6400 &mut files,
6401 &mut ignored,
6402 )?;
6403 Ok(IgnoredState { files, ignored })
6404}
6405
6406fn collect_untracked_files(
6407 root: &Path,
6408 repository: &Path,
6409 prefix: &Path,
6410 visited: &mut BTreeSet<PathBuf>,
6411 files: &mut BTreeMap<PathBuf, UntrackedFile>,
6412 ignored: &mut BTreeSet<PathBuf>,
6413) -> Result<()> {
6414 let canonical = std::fs::canonicalize(repository)
6415 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6416 if !visited.insert(canonical.clone()) {
6417 bail!("submodule recursion revisited {}", canonical.display());
6418 }
6419 let listed = run_git_bytes(
6423 repository,
6424 &["ls-files", "--others", "--exclude-standard", "-z"],
6425 )?;
6426 if !listed.is_empty() && !listed.ends_with(&[0]) {
6427 bail!(
6428 "git returned an unterminated untracked-file list for {}",
6429 repository.display()
6430 );
6431 }
6432
6433 for raw in listed
6434 .split(|byte| *byte == 0)
6435 .filter(|raw| !raw.is_empty())
6436 {
6437 let (relative, nested) = untracked_record(raw, "untracked")?;
6438 let from_root = prefix.join(&relative);
6439 if is_spar_own_path(&from_root) {
6440 continue;
6441 }
6442 let absolute = root.join(&from_root);
6443 let fingerprint = if nested {
6444 nested_repository_fingerprint(&absolute)?
6445 } else {
6446 ignored_file_fingerprint(&absolute)?
6447 };
6448 if files.insert(from_root.clone(), fingerprint).is_some() {
6449 bail!(
6450 "git returned the untracked path more than once: {:?}",
6451 from_root
6452 );
6453 }
6454 }
6455
6456 let ignored_listed = run_git_bytes(
6457 repository,
6458 &[
6459 "ls-files",
6460 "--others",
6461 "--ignored",
6462 "--exclude-standard",
6463 "-z",
6464 ],
6465 )?;
6466 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6467 bail!(
6468 "git returned an unterminated ignored-file list for {}",
6469 repository.display()
6470 );
6471 }
6472 for raw in ignored_listed
6478 .split(|byte| *byte == 0)
6479 .filter(|raw| !raw.is_empty())
6480 {
6481 let (relative, nested) = untracked_record(raw, "ignored")?;
6482 let from_root = prefix.join(relative);
6483 if is_spar_own_path(&from_root) {
6484 continue;
6485 }
6486 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
6487 let absolute = root.join(&from_root);
6488 let fingerprint = if nested {
6489 nested_repository_fingerprint(&absolute)?
6490 } else {
6491 ignored_file_fingerprint(&absolute)?
6492 };
6493 slot.insert(fingerprint);
6494 }
6495 if !ignored.insert(from_root.clone()) {
6496 bail!(
6497 "git returned the ignored path more than once: {:?}",
6498 from_root
6499 );
6500 }
6501 }
6502
6503 for link in gitlinks(repository)? {
6504 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6505 continue;
6506 };
6507 collect_untracked_files(
6508 root,
6509 &submodule,
6510 &prefix.join(&link.path),
6511 visited,
6512 files,
6513 ignored,
6514 )?;
6515 }
6516 Ok(())
6517}
6518
6519fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
6520 let argv = git_without_automation_argv(args);
6521 proc::run_bytes(
6522 &argv,
6523 &ExecOpts::new()
6524 .cwd(cwd)
6525 .timeout_secs(30)
6526 .stop_descendants(true),
6527 )
6528}
6529
6530fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
6531 let argv = git_without_automation_argv(args);
6532 proc::run(
6533 &argv,
6534 &ExecOpts::new()
6535 .cwd(cwd)
6536 .timeout_secs(30)
6537 .stop_descendants(true),
6538 )
6539}
6540
6541fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
6542 let path = path.to_str().ok_or_else(|| {
6543 spar_err!(
6544 "cannot verify filtered content for a non-UTF-8 path in {}",
6545 cwd.display()
6546 )
6547 })?;
6548 let path_arg = format!("--path={path}");
6549 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
6550 Ok(Sha256::digest(bytes).into())
6551}
6552
6553fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
6554 let relative = path_from_git_bytes(raw)?;
6555 if relative.is_absolute()
6556 || relative.components().any(|component| {
6557 matches!(
6558 component,
6559 std::path::Component::ParentDir
6560 | std::path::Component::RootDir
6561 | std::path::Component::Prefix(_)
6562 )
6563 })
6564 {
6565 bail!("git returned an unsafe {kind} path: {:?}", relative);
6566 }
6567 Ok(relative)
6568}
6569
6570fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6579 let nested = raw.last() == Some(&b'/');
6580 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6581 if trimmed.is_empty() {
6582 bail!("git returned an empty {kind} path");
6583 }
6584 Ok((safe_git_path(trimmed, kind)?, nested))
6585}
6586
6587fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6588 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6589 if !listed.is_empty() && !listed.ends_with(&[0]) {
6590 bail!(
6591 "git returned an unterminated index listing for {}",
6592 cwd.display()
6593 );
6594 }
6595 let mut entries = Vec::new();
6596 for record in listed
6597 .split(|byte| *byte == 0)
6598 .filter(|record| !record.is_empty())
6599 {
6600 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6601 bail!(
6602 "git returned a malformed index record for {}",
6603 cwd.display()
6604 );
6605 };
6606 let header = &record[..tab];
6607 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6608 if fields.len() != 3 {
6609 bail!(
6610 "git returned a malformed index header for {}",
6611 cwd.display()
6612 );
6613 }
6614 if fields[2] != b"0" {
6615 continue;
6616 }
6617 let mode = std::str::from_utf8(fields[0])
6618 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6619 .to_string();
6620 let oid = std::str::from_utf8(fields[1])
6621 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6622 .to_string();
6623 entries.push(IndexEntry {
6624 path: safe_git_path(&record[tab + 1..], "index")?,
6625 mode,
6626 oid,
6627 });
6628 }
6629 Ok(entries)
6630}
6631
6632fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6634 let listed = run_git_bytes(
6635 cwd,
6636 &[
6637 "ls-files",
6638 "--others",
6639 "--exclude-standard",
6640 "-z",
6641 "--",
6642 ".gitattributes",
6643 ":(glob)**/.gitattributes",
6644 ],
6645 )?;
6646 if !listed.is_empty() && !listed.ends_with(&[0]) {
6647 bail!(
6648 "git returned an unterminated attribute-file listing for {}",
6649 cwd.display()
6650 );
6651 }
6652 listed
6653 .split(|byte| *byte == 0)
6654 .filter(|record| !record.is_empty())
6655 .map(|raw| safe_git_path(raw, "attribute"))
6656 .collect()
6657}
6658
6659fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6660 if !untracked_attribute_paths(cwd)?.is_empty() {
6661 return Ok(true);
6662 }
6663
6664 let index = index_entries(cwd)?
6665 .into_iter()
6666 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6667 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6668 .collect::<BTreeMap<_, _>>();
6669 let head = tree_entries(cwd, "HEAD")?
6670 .into_iter()
6671 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6672 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6673 .collect::<BTreeMap<_, _>>();
6674 if index != head {
6675 return Ok(true);
6676 }
6677
6678 let effective = check_attributes(cwd, index.keys().cloned())?;
6679 let config = CheckoutConfig::read(cwd)?;
6680 for (path, (_mode, oid)) in index {
6681 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6682 return Ok(true);
6683 };
6684 let attributes = effective
6685 .get(&path)
6686 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6687 if allows_expected_crlf(&config, attributes)? {
6688 if worktree.mode == "120000" {
6689 return Ok(true);
6690 }
6691 let (normalized, every_lf_was_crlf) =
6692 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
6693 if !every_lf_was_crlf || normalized != oid {
6694 return Ok(true);
6695 }
6696 } else if worktree.raw_oid != oid {
6697 return Ok(true);
6698 }
6699 }
6700 Ok(false)
6701}
6702
6703fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
6704 Ok(index_entries(cwd)?
6705 .into_iter()
6706 .filter(|entry| entry.mode == "160000")
6707 .map(|entry| Gitlink {
6708 path: entry.path,
6709 oid: entry.oid,
6710 })
6711 .collect())
6712}
6713
6714fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
6715 let mut tracked = BTreeMap::new();
6716 for entry in index_entries(cwd)? {
6717 if entry.mode == "160000" {
6718 continue;
6719 }
6720 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
6721 tracked.insert(
6722 entry.path,
6723 TrackedEntry {
6724 index_mode: entry.mode,
6725 index_oid: entry.oid,
6726 worktree,
6727 },
6728 );
6729 }
6730 Ok(tracked)
6731}
6732
6733fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
6734 let metadata = match std::fs::symlink_metadata(path) {
6735 Ok(metadata) => metadata,
6736 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6737 Err(e) => {
6738 return Err(spar_err!(
6739 "could not inspect tracked file {}: {e}",
6740 path.display()
6741 ))
6742 }
6743 };
6744 let mut fingerprint = Sha256::new();
6745 if metadata.file_type().is_symlink() {
6746 let target = std::fs::read_link(path)
6747 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
6748 let bytes = os_str_bytes(target.as_os_str())?;
6749 fingerprint.update(b"symlink\0");
6750 fingerprint.update(&bytes);
6751 let content = Sha256::digest(&bytes).into();
6752 return Ok(Some(WorktreeFile {
6753 mode: "120000".to_string(),
6754 #[cfg(unix)]
6755 permissions: 0,
6756 raw_oid: git_blob_oid(oid_len, &bytes)?,
6757 fingerprint: fingerprint.finalize().into(),
6758 content,
6759 }));
6760 }
6761 if !metadata.is_file() {
6762 bail!("tracked path {} is not a file or symlink", path.display());
6763 }
6764
6765 let mut options = OpenOptions::new();
6766 options.read(true);
6767 #[cfg(unix)]
6768 {
6769 use std::os::unix::fs::OpenOptionsExt;
6770 options.custom_flags(libc::O_NOFOLLOW);
6771 }
6772 let mut file = options
6773 .open(path)
6774 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6775 let before = file
6776 .metadata()
6777 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6778 let mode = tracked_file_mode(&before);
6779 #[cfg(unix)]
6780 let permissions = {
6781 use std::os::unix::fs::MetadataExt;
6782 before.mode() & 0o7777
6783 };
6784 fingerprint.update(b"file\0");
6785 fingerprint.update(mode.as_bytes());
6786 #[cfg(unix)]
6787 fingerprint.update(permissions.to_le_bytes());
6788 fingerprint.update(before.len().to_le_bytes());
6789 let mut content = Sha256::new();
6790 let header = format!("blob {}\0", before.len());
6791 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6792 let mut buf = [0u8; 64 * 1024];
6793 loop {
6794 let read = file
6795 .read(&mut buf)
6796 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6797 if read == 0 {
6798 break;
6799 }
6800 fingerprint.update(&buf[..read]);
6801 content.update(&buf[..read]);
6802 object.update(&buf[..read]);
6803 }
6804 let after = file
6805 .metadata()
6806 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6807 if before.len() != after.len()
6808 || before.modified().ok() != after.modified().ok()
6809 || before.permissions() != after.permissions()
6810 {
6811 bail!(
6812 "tracked file {} changed while it was being inspected",
6813 path.display()
6814 );
6815 }
6816 let current = std::fs::symlink_metadata(path)
6817 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6818 if !same_file(&after, ¤t) {
6819 bail!(
6820 "tracked file {} was replaced while it was being inspected",
6821 path.display()
6822 );
6823 }
6824 Ok(Some(WorktreeFile {
6825 mode,
6826 #[cfg(unix)]
6827 permissions,
6828 raw_oid: object.finish(),
6829 fingerprint: fingerprint.finalize().into(),
6830 content: content.finalize().into(),
6831 }))
6832}
6833
6834fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
6835 let metadata = std::fs::symlink_metadata(path)
6836 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6837 let mut digest = Sha256::new();
6838 if metadata.file_type().is_symlink() {
6839 digest.update(b"symlink\0");
6840 let target = std::fs::read_link(path)
6841 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
6842 digest.update(os_str_bytes(target.as_os_str())?);
6843 return Ok(digest.finalize().into());
6844 }
6845 if !metadata.is_file() {
6846 bail!("attribute path {} is not a file or symlink", path.display());
6847 }
6848 let mut options = OpenOptions::new();
6849 options.read(true);
6850 #[cfg(unix)]
6851 {
6852 use std::os::unix::fs::OpenOptionsExt;
6853 options.custom_flags(libc::O_NOFOLLOW);
6854 }
6855 let mut file = options
6856 .open(path)
6857 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6858 let before = file
6859 .metadata()
6860 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6861 digest.update(b"file\0");
6862 let mut buf = [0u8; 64 * 1024];
6863 loop {
6864 let read = file
6865 .read(&mut buf)
6866 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6867 if read == 0 {
6868 break;
6869 }
6870 digest.update(&buf[..read]);
6871 }
6872 let after = file
6873 .metadata()
6874 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6875 let current = std::fs::symlink_metadata(path)
6876 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6877 if before.len() != after.len()
6878 || before.modified().ok() != after.modified().ok()
6879 || !same_file(&after, ¤t)
6880 {
6881 bail!(
6882 "attribute file {} changed while it was being inspected",
6883 path.display()
6884 );
6885 }
6886 Ok(digest.finalize().into())
6887}
6888
6889enum ObjectHasher {
6890 Sha1(Sha1),
6891 Sha256(Sha256),
6892}
6893
6894impl ObjectHasher {
6895 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
6896 let mut hasher = match oid_len {
6897 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
6898 64 => Self::Sha256(Sha256::new()),
6899 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
6900 };
6901 hasher.update(header);
6902 Ok(hasher)
6903 }
6904
6905 fn update(&mut self, bytes: &[u8]) {
6906 match self {
6907 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
6908 Self::Sha256(hasher) => hasher.update(bytes),
6909 }
6910 }
6911
6912 fn finish(self) -> String {
6913 let bytes = match self {
6914 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
6915 Self::Sha256(hasher) => hasher.finalize().to_vec(),
6916 };
6917 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
6918 }
6919}
6920
6921fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
6922 let header = format!("blob {}\0", bytes.len());
6923 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
6924 hasher.update(bytes);
6925 Ok(hasher.finish())
6926}
6927
6928fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
6929 let mut first = open_regular_file(path)?;
6930 let first_before = first
6931 .metadata()
6932 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6933 let mut raw_len = 0u64;
6934 let mut crlf_pairs = 0u64;
6935 let mut previous_was_cr = false;
6936 let mut every_lf_was_crlf = true;
6937 let mut buf = [0u8; 64 * 1024];
6938 loop {
6939 let read = first
6940 .read(&mut buf)
6941 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6942 if read == 0 {
6943 break;
6944 }
6945 raw_len = raw_len
6946 .checked_add(read as u64)
6947 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
6948 for byte in &buf[..read] {
6949 if *byte == b'\n' {
6950 if previous_was_cr {
6951 crlf_pairs += 1;
6952 } else {
6953 every_lf_was_crlf = false;
6954 }
6955 }
6956 previous_was_cr = *byte == b'\r';
6957 }
6958 }
6959 let first_after = first
6960 .metadata()
6961 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6962 let current = std::fs::symlink_metadata(path)
6963 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6964 if raw_len != first_before.len()
6965 || !stable_file_metadata(&first_before, &first_after)
6966 || !stable_file_metadata(&first_after, ¤t)
6967 {
6968 bail!(
6969 "tracked file {} changed while line endings were inspected",
6970 path.display()
6971 );
6972 }
6973
6974 let normalized_len = raw_len
6975 .checked_sub(crlf_pairs)
6976 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
6977 let header = format!("blob {normalized_len}\0");
6978 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6979 let mut second = open_regular_file(path)?;
6980 let second_before = second
6981 .metadata()
6982 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6983 if !stable_file_metadata(&first_after, &second_before) {
6984 bail!(
6985 "tracked file {} changed between line-ending checks",
6986 path.display()
6987 );
6988 }
6989 let mut pending_cr = false;
6990 loop {
6991 let read = second
6992 .read(&mut buf)
6993 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6994 if read == 0 {
6995 break;
6996 }
6997 for byte in &buf[..read] {
6998 if pending_cr {
6999 if *byte == b'\n' {
7000 object.update(b"\n");
7001 pending_cr = false;
7002 continue;
7003 }
7004 object.update(b"\r");
7005 pending_cr = false;
7006 }
7007 if *byte == b'\r' {
7008 pending_cr = true;
7009 } else {
7010 object.update(std::slice::from_ref(byte));
7011 }
7012 }
7013 }
7014 if pending_cr {
7015 object.update(b"\r");
7016 }
7017 let second_after = second
7018 .metadata()
7019 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7020 let current = std::fs::symlink_metadata(path)
7021 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7022 if !stable_file_metadata(&second_before, &second_after)
7023 || !stable_file_metadata(&second_after, ¤t)
7024 {
7025 bail!(
7026 "tracked file {} changed while line endings were hashed",
7027 path.display()
7028 );
7029 }
7030 Ok((object.finish(), every_lf_was_crlf))
7031}
7032
7033fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7034 let mut options = OpenOptions::new();
7035 options.read(true);
7036 #[cfg(unix)]
7037 {
7038 use std::os::unix::fs::OpenOptionsExt;
7039 options.custom_flags(libc::O_NOFOLLOW);
7040 }
7041 let file = options
7042 .open(path)
7043 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7044 let metadata = file
7045 .metadata()
7046 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7047 if !metadata.is_file() {
7048 bail!("tracked path {} is not a regular file", path.display());
7049 }
7050 Ok(file)
7051}
7052
7053fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7054 if !same_file(left, right)
7055 || left.len() != right.len()
7056 || left.modified().ok() != right.modified().ok()
7057 || left.permissions() != right.permissions()
7058 {
7059 return false;
7060 }
7061 #[cfg(unix)]
7062 {
7063 use std::os::unix::fs::MetadataExt;
7064 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7065 }
7066 #[cfg(not(unix))]
7067 {
7068 left.created().ok() == right.created().ok()
7069 }
7070}
7071
7072const ATTRIBUTE_BATCH: usize = 1000;
7079
7080fn check_attributes(
7081 cwd: &Path,
7082 paths: impl IntoIterator<Item = PathBuf>,
7083) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7084 let paths = paths.into_iter().collect::<BTreeSet<_>>();
7085 if paths.is_empty() {
7086 return Ok(BTreeMap::new());
7087 }
7088 let ordered = paths.into_iter().collect::<Vec<_>>();
7089 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7090 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7091 values.extend(check_attribute_batch(cwd, batch)?);
7092 }
7093 Ok(values)
7094}
7095
7096fn check_attribute_batch(
7097 cwd: &Path,
7098 batch: &[PathBuf],
7099) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7100 const NAMES: [&str; 6] = [
7101 "filter",
7102 "working-tree-encoding",
7103 "ident",
7104 "text",
7105 "eol",
7106 "crlf",
7107 ];
7108 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7109 let mut input = String::new();
7110 for path in &paths {
7111 let path = path.to_str().ok_or_else(|| {
7112 spar_err!(
7113 "cannot inspect attributes for a non-UTF-8 path in {}",
7114 cwd.display()
7115 )
7116 })?;
7117 input.push_str(path);
7118 input.push('\0');
7119 }
7120 let argv = git_without_automation_argv(&[
7121 "check-attr",
7122 "-z",
7123 "--cached",
7124 "--stdin",
7125 "filter",
7126 "working-tree-encoding",
7127 "ident",
7128 "text",
7129 "eol",
7130 "crlf",
7131 ]);
7132 let output = proc::run_bytes(
7133 &argv,
7134 &ExecOpts::new()
7135 .cwd(cwd)
7136 .timeout_secs(30)
7137 .stdin(input)
7138 .stop_descendants(true),
7139 )?;
7140 if !output.is_empty() && !output.ends_with(&[0]) {
7141 bail!(
7142 "git returned an unterminated attribute result for {}",
7143 cwd.display()
7144 );
7145 }
7146 let fields = output
7147 .split(|byte| *byte == 0)
7148 .filter(|field| !field.is_empty())
7149 .collect::<Vec<_>>();
7150 if fields.len() != paths.len() * NAMES.len() * 3 {
7151 bail!(
7152 "git returned an unexpected attribute result for {}",
7153 cwd.display()
7154 );
7155 }
7156 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7157 for record in fields.chunks_exact(3) {
7158 let path = safe_git_path(record[0], "attribute")?;
7159 if !paths.contains(&path) {
7160 bail!(
7161 "git returned attributes for the wrong path in {}",
7162 cwd.display()
7163 );
7164 }
7165 let name = std::str::from_utf8(record[1])
7166 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7167 let value = std::str::from_utf8(record[2])
7168 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7169 values
7170 .entry(path)
7171 .or_default()
7172 .insert(name.to_string(), value.to_string());
7173 }
7174 if paths.iter().any(|path| {
7175 values
7176 .get(path)
7177 .is_none_or(|attributes| attributes.len() != NAMES.len())
7178 }) {
7179 bail!(
7180 "git omitted an attribute result for a tracked path in {}",
7181 cwd.display()
7182 );
7183 }
7184 Ok(values)
7185}
7186
7187fn attribute_is_active(value: Option<&String>) -> bool {
7188 !matches!(
7189 value.map(String::as_str),
7190 None | Some("unspecified") | Some("unset")
7191 )
7192}
7193
7194fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7195 attribute_is_active(values.get("filter"))
7196 || attribute_is_active(values.get("working-tree-encoding"))
7197}
7198
7199struct CheckoutConfig {
7208 autocrlf: Option<String>,
7209 eol: Option<String>,
7210 symlinks: Option<bool>,
7211}
7212
7213impl CheckoutConfig {
7214 fn read(cwd: &Path) -> Result<Self> {
7215 Ok(Self {
7216 autocrlf: git_config_value(cwd, "core.autocrlf")?,
7217 eol: git_config_value(cwd, "core.eol")?,
7218 symlinks: git_config_bool(cwd, "core.symlinks")?,
7219 })
7220 }
7221
7222 fn autocrlf_is_true(&self) -> bool {
7223 self.autocrlf.as_ref().is_some_and(|value| {
7224 matches!(
7225 value.to_ascii_lowercase().as_str(),
7226 "true" | "yes" | "on" | "1"
7227 )
7228 })
7229 }
7230
7231 fn eol_is(&self, wanted: &str) -> bool {
7232 self.eol
7233 .as_ref()
7234 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7235 }
7236}
7237
7238fn path_has_ambiguous_transform(
7239 config: &CheckoutConfig,
7240 values: &BTreeMap<String, String>,
7241) -> Result<bool> {
7242 if path_has_external_transform(values)
7243 || attribute_is_active(values.get("ident"))
7244 || attribute_is_active(values.get("crlf"))
7245 {
7246 return Ok(true);
7247 }
7248 let text = values.get("text").map(String::as_str);
7249 let eol = values.get("eol").map(String::as_str);
7250 if text == Some("auto") {
7251 return Ok(true);
7252 }
7253 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7254 || !matches!(
7255 eol,
7256 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7257 )
7258 {
7259 return Ok(true);
7260 }
7261 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7262 return Ok(config.autocrlf_is_true());
7263 }
7264 Ok(false)
7265}
7266
7267fn allows_expected_crlf(
7268 config: &CheckoutConfig,
7269 values: &BTreeMap<String, String>,
7270) -> Result<bool> {
7271 if path_has_external_transform(values)
7272 || attribute_is_active(values.get("ident"))
7273 || attribute_is_active(values.get("crlf"))
7274 {
7275 return Ok(false);
7276 }
7277 let text = values.get("text").map(String::as_str);
7278 let eol = values.get("eol").map(String::as_str);
7279 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7280 return Ok(false);
7281 }
7282 if eol == Some("crlf") {
7283 return Ok(true);
7284 }
7285 if text != Some("set") {
7286 return Ok(false);
7287 }
7288 if let Some(autocrlf) = config.autocrlf.as_deref() {
7289 match autocrlf.to_ascii_lowercase().as_str() {
7290 "true" | "yes" | "on" | "1" => return Ok(true),
7291 "input" => return Ok(false),
7292 _ => {}
7293 }
7294 }
7295 if config.eol_is("crlf") {
7296 return Ok(true);
7297 }
7298 #[cfg(windows)]
7299 if config.eol.is_none() || config.eol_is("native") {
7300 return Ok(true);
7301 }
7302 Ok(false)
7303}
7304
7305fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7306 let argv = git_without_automation_argv(&["config", "--get", key]);
7307 let output = proc::exec(
7308 &argv,
7309 &ExecOpts::new()
7310 .cwd(cwd)
7311 .timeout_secs(30)
7312 .check(false)
7313 .stop_descendants(true),
7314 )?;
7315 match output.code {
7316 0 => Ok(Some(output.stdout.trim().to_string())),
7317 1 => Ok(None),
7318 _ => bail!(
7319 "could not read Git configuration in {}: {}",
7320 cwd.display(),
7321 output.stderr.trim()
7322 ),
7323 }
7324}
7325
7326fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7327 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7328 let output = proc::exec(
7329 &argv,
7330 &ExecOpts::new()
7331 .cwd(cwd)
7332 .timeout_secs(30)
7333 .check(false)
7334 .stop_descendants(true),
7335 )?;
7336 match output.code {
7337 0 if output.stdout.trim() == "true" => Ok(Some(true)),
7338 0 if output.stdout.trim() == "false" => Ok(Some(false)),
7339 0 => bail!(
7340 "git returned an invalid boolean for {key} in {}",
7341 cwd.display()
7342 ),
7343 1 => Ok(None),
7344 _ => bail!(
7345 "could not read Git configuration in {}: {}",
7346 cwd.display(),
7347 output.stderr.trim()
7348 ),
7349 }
7350}
7351
7352#[cfg(unix)]
7353fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7354 use std::os::unix::fs::PermissionsExt;
7355 if metadata.permissions().mode() & 0o111 == 0 {
7356 "100644".to_string()
7357 } else {
7358 "100755".to_string()
7359 }
7360}
7361
7362#[cfg(not(unix))]
7363fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7364 "100644".to_string()
7365}
7366
7367fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7368 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7369 if !listed.is_empty() && !listed.ends_with(&[0]) {
7370 bail!(
7371 "git returned an unterminated tree listing for {}",
7372 cwd.display()
7373 );
7374 }
7375 let mut entries = Vec::new();
7376 for record in listed
7377 .split(|byte| *byte == 0)
7378 .filter(|record| !record.is_empty())
7379 {
7380 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7381 bail!("git returned a malformed tree record for {}", cwd.display());
7382 };
7383 let fields = record[..tab]
7384 .split(|byte| *byte == b' ')
7385 .collect::<Vec<_>>();
7386 if fields.len() != 3 {
7387 bail!("git returned a malformed tree header for {}", cwd.display());
7388 }
7389 let mode = std::str::from_utf8(fields[0])
7390 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7391 .to_string();
7392 let oid = std::str::from_utf8(fields[2])
7393 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7394 .to_string();
7395 entries.push(IndexEntry {
7396 path: safe_git_path(&record[tab + 1..], "tree")?,
7397 mode,
7398 oid,
7399 });
7400 }
7401 Ok(entries)
7402}
7403
7404fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7405 Ok(tree_entries(cwd, "HEAD")?
7406 .into_iter()
7407 .filter(|entry| entry.mode == "160000")
7408 .map(|entry| (entry.path, entry.oid))
7409 .collect())
7410}
7411
7412fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7413 let head = head_gitlinks(cwd)?;
7414 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7415 .into_iter()
7416 .map(|link| (link.path, link.oid))
7417 .collect();
7418 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7419 paths.extend(index.keys().cloned());
7420 Ok(paths
7421 .into_iter()
7422 .filter(|path| head.get(path) != index.get(path))
7423 .collect())
7424}
7425
7426fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7427 let path = parent.join(relative);
7428 let metadata = match std::fs::symlink_metadata(&path) {
7429 Ok(metadata) => metadata,
7430 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7431 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7432 };
7433 if !metadata.is_dir() {
7434 bail!("the gitlink at {} is not a directory", path.display());
7435 }
7436 let canonical = std::fs::canonicalize(&path)
7437 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7438 if canonical != path {
7439 bail!(
7440 "the gitlink at {} resolves through a symlink",
7441 path.display()
7442 );
7443 }
7444 if !path.join(".git").exists() {
7445 let empty = std::fs::read_dir(&path)
7446 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7447 .next()
7448 .is_none();
7449 if empty {
7450 return Ok(None);
7451 }
7452 bail!(
7453 "the uninitialized gitlink at {} contains local files",
7454 path.display()
7455 );
7456 }
7457 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7458 if inside.trim() != "true" {
7459 bail!("the gitlink at {} is not a worktree", path.display());
7460 }
7461 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7462 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7463 spar_err!(
7464 "could not resolve the gitlink top level at {}: {e}",
7465 path.display()
7466 )
7467 })?;
7468 if top != canonical {
7469 bail!(
7470 "the gitlink at {} belongs to a different worktree",
7471 path.display()
7472 );
7473 }
7474 Ok(Some(canonical))
7475}
7476
7477fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
7491 let root = std::fs::canonicalize(cwd)
7492 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7493 let mut allowed = BTreeSet::from([root.join(".git")]);
7494 let mut checkouts = BTreeSet::new();
7495 let mut repositories = vec![root.clone()];
7496 let mut visited = BTreeSet::new();
7497 while let Some(repository) = repositories.pop() {
7498 let canonical = std::fs::canonicalize(&repository)
7499 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7500 if !visited.insert(canonical.clone()) {
7501 bail!("submodule recursion revisited {}", canonical.display());
7502 }
7503 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
7504 let (path, nested) = untracked_record(&raw, "untracked")?;
7505 if nested {
7506 checkouts.insert(canonical.join(path));
7507 }
7508 }
7509 for link in gitlinks(&canonical)? {
7510 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
7511 continue;
7512 };
7513 allowed.insert(submodule.join(".git"));
7514 repositories.push(submodule);
7515 }
7516 }
7517
7518 let scan_root = root.clone();
7522 let mut directories = vec![root];
7523 while let Some(directory) = directories.pop() {
7524 let entries = match std::fs::read_dir(&directory) {
7525 Ok(entries) => entries,
7526 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7527 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7528 };
7529 for entry in entries {
7530 let entry = match entry {
7531 Ok(entry) => entry,
7532 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7533 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7534 };
7535 let path = entry.path();
7536 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
7537 continue;
7538 }
7539 if entry.file_name() == OsStr::new(".git") {
7540 if !allowed.contains(&path) {
7541 return Ok(Some(path));
7542 }
7543 continue;
7544 }
7545 let kind = match entry.file_type() {
7546 Ok(kind) => kind,
7547 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7548 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7549 };
7550 if kind.is_dir() && !checkouts.contains(&path) {
7551 directories.push(path);
7552 }
7553 }
7554 }
7555 Ok(None)
7556}
7557
7558pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
7559 if let Some(path) = unexpected_nested_git_entry(cwd)? {
7560 bail!(
7561 "the worktree contains a Git entry at {} that Git does not report as a nested \
7562 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
7563 prove they are unchanged.",
7564 path.display()
7565 );
7566 }
7567 let root = std::fs::canonicalize(cwd)
7568 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7569 let mut repositories = BTreeMap::new();
7570 let mut visited = BTreeSet::new();
7571 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7572 Ok(GitState { repositories })
7573}
7574
7575fn collect_git_state(
7576 repository: &Path,
7577 prefix: &Path,
7578 visited: &mut BTreeSet<PathBuf>,
7579 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7580) -> Result<()> {
7581 let canonical = std::fs::canonicalize(repository)
7582 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7583 if !visited.insert(canonical.clone()) {
7584 bail!("submodule recursion revisited {}", canonical.display());
7585 }
7586 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7587 let head = head.trim().to_string();
7588 if head.is_empty() {
7589 bail!("git returned an empty head for {}", repository.display());
7590 }
7591 let unsafe_index_flags = unsafe_index_flags(repository)?;
7592 let tracked = tracked_entries(repository)?;
7593 let gitlinks = gitlinks(repository)?;
7594 if repositories
7595 .insert(
7596 prefix.to_path_buf(),
7597 RepositoryState {
7598 head,
7599 unsafe_index_flags,
7600 tracked,
7601 gitlinks: gitlinks
7602 .iter()
7603 .map(|link| (link.path.clone(), link.oid.clone()))
7604 .collect(),
7605 },
7606 )
7607 .is_some()
7608 {
7609 bail!("Git state contains duplicate repository path {:?}", prefix);
7610 }
7611
7612 for link in gitlinks {
7613 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7614 continue;
7615 };
7616 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7617 }
7618 Ok(())
7619}
7620
7621fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7622 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7623 if !listed.is_empty() && !listed.ends_with(&[0]) {
7624 bail!(
7625 "git returned an unterminated index-flag listing for {}",
7626 cwd.display()
7627 );
7628 }
7629 let mut unsafe_records = Vec::new();
7630 for record in listed
7631 .split(|byte| *byte == 0)
7632 .filter(|record| !record.is_empty())
7633 {
7634 if record.len() < 3 || record[1] != b' ' {
7635 bail!(
7636 "git returned a malformed index-flag record for {}",
7637 cwd.display()
7638 );
7639 }
7640 if record[0] != b'H' {
7641 unsafe_records.extend_from_slice(record);
7642 unsafe_records.push(0);
7643 }
7644 }
7645 Ok(unsafe_records)
7646}
7647
7648pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7649 safe_git_state(cwd).map(|_| ())
7650}
7651
7652pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7653 let state = git_state(cwd)?;
7654 if let Some((path, _repository)) = state
7655 .repositories
7656 .iter()
7657 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7658 {
7659 let label = if path.as_os_str().is_empty() {
7660 cwd.to_path_buf()
7661 } else {
7662 cwd.join(path)
7663 };
7664 bail!(
7665 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7666 SPAR cannot prove the working files are unchanged, so it was kept.",
7667 label.display()
7668 );
7669 }
7670 Ok(state)
7671}
7672
7673fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7674 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7675}
7676
7677fn repository_has_recoverable_work_with(
7678 cwd: &Path,
7679 include_ignored: bool,
7680 disposable: Disposable,
7681) -> Result<bool> {
7682 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7683 return Ok(true);
7684 }
7685 let mut visited = BTreeSet::new();
7686 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7687}
7688
7689fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7690 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7691 let git_dir = PathBuf::from(git_dir.trim());
7692 let git_dir = if git_dir.is_absolute() {
7693 git_dir
7694 } else {
7695 cwd.join(git_dir)
7696 };
7697 let git_dir = std::fs::canonicalize(&git_dir)
7698 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
7699 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
7700 Ok(_) => return Ok(true),
7701 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7702 Err(error) => {
7703 return Err(spar_err!(
7704 "could not inspect per-worktree configuration in {}: {error}",
7705 git_dir.display()
7706 ))
7707 }
7708 }
7709
7710 let orig_head = git_dir.join("ORIG_HEAD");
7711 match std::fs::symlink_metadata(&orig_head) {
7712 Ok(metadata) if metadata.is_file() => {
7713 let oid = std::fs::read_to_string(&orig_head)
7714 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
7715 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
7716 return Ok(true);
7717 };
7718 if !commit_has_shared_ref(cwd, &commit)? {
7719 return Ok(true);
7720 }
7721 }
7722 Ok(_) => return Ok(true),
7723 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7724 Err(error) => {
7725 return Err(spar_err!(
7726 "could not inspect {}: {error}",
7727 orig_head.display()
7728 ))
7729 }
7730 }
7731
7732 let edit_message = git_dir.join("COMMIT_EDITMSG");
7733 match std::fs::symlink_metadata(&edit_message) {
7734 Ok(metadata) if metadata.is_file() => {
7735 let draft = std::fs::read(&edit_message)
7736 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
7737 if draft != head_commit_message(cwd)? {
7738 return Ok(true);
7739 }
7740 }
7741 Ok(_) => return Ok(true),
7742 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7743 Err(error) => {
7744 return Err(spar_err!(
7745 "could not inspect {}: {error}",
7746 edit_message.display()
7747 ))
7748 }
7749 }
7750
7751 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
7752 return Ok(true);
7753 }
7754
7755 let local_refs = run_git_bytes(
7756 cwd,
7757 &[
7758 "for-each-ref",
7759 "--format=%(refname)",
7760 "refs/worktree",
7761 "refs/bisect",
7762 "refs/rewritten",
7763 ],
7764 )?;
7765 if !local_refs.is_empty() {
7766 return Ok(true);
7767 }
7768
7769 for entry in std::fs::read_dir(&git_dir)
7773 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
7774 {
7775 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
7776 let known = matches!(
7777 entry.file_name().to_str(),
7778 Some(
7779 "HEAD"
7780 | "ORIG_HEAD"
7781 | "COMMIT_EDITMSG"
7782 | "FETCH_HEAD"
7783 | "commondir"
7784 | "gitdir"
7785 | "index"
7786 | "logs"
7787 | "refs"
7788 )
7789 );
7790 if !known {
7791 return Ok(true);
7792 }
7793 }
7794
7795 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7796 if !commit_has_shared_ref(cwd, head.trim())? {
7797 return Ok(true);
7798 }
7799 Ok(false)
7800}
7801
7802fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
7803 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
7804 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
7805 bail!(
7806 "git returned a commit without a message separator in {}",
7807 cwd.display()
7808 );
7809 };
7810 Ok(commit[split + 2..].to_vec())
7811}
7812
7813fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
7814 let metadata = match std::fs::symlink_metadata(logs) {
7815 Ok(metadata) => metadata,
7816 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
7817 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
7818 };
7819 if !metadata.is_dir() {
7820 return Ok(true);
7821 }
7822 let mut files = Vec::new();
7823 let mut directories = vec![logs.to_path_buf()];
7824 while let Some(directory) = directories.pop() {
7825 for entry in std::fs::read_dir(&directory)
7826 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
7827 {
7828 let entry =
7829 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
7830 let path = entry.path();
7831 let kind = entry
7832 .file_type()
7833 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
7834 if kind.is_dir() {
7835 directories.push(path);
7836 } else if kind.is_file() {
7837 files.push(path);
7838 } else {
7839 return Ok(true);
7840 }
7841 }
7842 }
7843
7844 let mut commits = BTreeSet::new();
7845 for path in files {
7846 if !collect_reflog_commits(cwd, &path, &mut commits)? {
7847 return Ok(true);
7848 }
7849 }
7850 for commit in commits {
7851 if !commit_has_shared_ref(cwd, &commit)? {
7852 return Ok(true);
7853 }
7854 }
7855 Ok(false)
7856}
7857
7858fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
7862 let common = common_git_dir(cwd)?;
7863 let reflog = common.join("logs").join(refname);
7864 let metadata = match std::fs::symlink_metadata(&reflog) {
7865 Ok(metadata) => metadata,
7866 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
7867 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
7868 };
7869 if !metadata.is_file() {
7870 return Ok(false);
7871 }
7872 let mut commits = BTreeSet::new();
7873 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
7874 return Ok(false);
7875 }
7876 for commit in commits {
7877 if is_ancestor(cwd, &commit, durable_tip)?
7878 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
7879 {
7880 continue;
7881 }
7882 return Ok(false);
7883 }
7884 Ok(true)
7885}
7886
7887fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
7888 let file = std::fs::File::open(path)
7889 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7890 for line in std::io::BufReader::new(file).lines() {
7891 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7892 let mut fields = line.splitn(3, ' ');
7893 let Some(old) = fields.next() else {
7894 return Ok(false);
7895 };
7896 let Some(new) = fields.next() else {
7897 return Ok(false);
7898 };
7899 if fields.next().is_none() {
7900 return Ok(false);
7901 }
7902 for oid in [old, new] {
7903 if oid.bytes().all(|byte| byte == b'0') {
7904 continue;
7905 }
7906 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
7907 return Ok(false);
7908 };
7909 commits.insert(commit);
7910 }
7911 }
7912 Ok(true)
7913}
7914
7915fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
7916 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
7917 let path = PathBuf::from(raw.trim());
7918 let path = if path.is_absolute() {
7919 path
7920 } else {
7921 cwd.join(path)
7922 };
7923 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
7924}
7925
7926fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
7927 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
7928 let output = proc::exec(
7929 &argv,
7930 &ExecOpts::new()
7931 .cwd(cwd)
7932 .timeout_secs(30)
7933 .check(false)
7934 .stop_descendants(true),
7935 )?;
7936 match output.code {
7937 0 => Ok(true),
7938 1 => Ok(false),
7939 _ => bail!("{}", proc::failure_message(&argv, &output)),
7940 }
7941}
7942
7943fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
7944 let commit = format!("{oid}^{{commit}}");
7945 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
7946 let output = proc::exec(
7947 &argv,
7948 &ExecOpts::new()
7949 .cwd(cwd)
7950 .timeout_secs(30)
7951 .check(false)
7952 .stop_descendants(true),
7953 )?;
7954 if output.code != 0 {
7955 return Ok(None);
7956 }
7957 let oid = output.stdout.trim();
7958 if oid.is_empty() {
7959 return Ok(None);
7960 }
7961 Ok(Some(oid.to_string()))
7962}
7963
7964fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
7965 commit_has_shared_ref_except(cwd, oid, None)
7966}
7967
7968fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
7969 let contains = format!("--contains={oid}");
7970 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
7971 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
7972 !record.is_empty()
7973 && !record.starts_with(b"refs/worktree/")
7974 && !record.starts_with(b"refs/bisect/")
7975 && !record.starts_with(b"refs/rewritten/")
7976 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
7977 }))
7978}
7979
7980fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
7995 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
7996 for raw in &ordinary {
7997 let (path, _) = untracked_record(raw, "untracked")?;
7998 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
7999 continue;
8000 }
8001 return Ok(true);
8002 }
8003 let listed = untracked_listing(
8004 cwd,
8005 &[
8006 "ls-files",
8007 "--others",
8008 "--ignored",
8009 "--exclude-standard",
8010 "-z",
8011 ],
8012 )?;
8013 for raw in listed {
8014 let (path, nested) = untracked_record(&raw, "ignored")?;
8015 if nested {
8019 return Ok(true);
8020 }
8021 if disposable == Disposable::BuildOutput
8022 && !is_generated_artifact(&path)
8023 && !is_desktop_metadata(&path)
8024 {
8025 return Ok(true);
8026 }
8027 }
8028 Ok(false)
8029}
8030
8031fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8032 let listed = run_git_bytes(cwd, args)?;
8033 if !listed.is_empty() && !listed.ends_with(&[0]) {
8034 bail!(
8035 "git returned an unterminated untracked-file list for {}",
8036 cwd.display()
8037 );
8038 }
8039 Ok(listed
8040 .split(|byte| *byte == 0)
8041 .filter(|raw| !raw.is_empty())
8042 .map(|raw| raw.to_vec())
8043 .collect())
8044}
8045
8046fn repository_has_recoverable_work_inner(
8047 cwd: &Path,
8048 include_ignored: bool,
8049 disposable: Disposable,
8050 visited: &mut BTreeSet<PathBuf>,
8051) -> Result<bool> {
8052 let canonical = std::fs::canonicalize(cwd)
8053 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8054 if !visited.insert(canonical.clone()) {
8055 bail!("submodule recursion revisited {}", canonical.display());
8056 }
8057 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8058 return Ok(true);
8059 }
8060 if !unsafe_index_flags(cwd)?.is_empty() {
8061 return Ok(true);
8062 }
8063 if attributes_may_be_modified(cwd)? {
8064 return Ok(true);
8065 }
8066 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8067 return Ok(true);
8068 }
8069 if include_ignored {
8070 let index = index_entries(cwd)?
8071 .into_iter()
8072 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8073 .collect::<BTreeMap<_, _>>();
8074 let head = tree_entries(cwd, "HEAD")?
8075 .into_iter()
8076 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8077 .collect::<BTreeMap<_, _>>();
8078 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8079 return Ok(true);
8080 }
8081 let tracked = tracked_entries(cwd)?;
8082 let effective = check_attributes(cwd, tracked.keys().cloned())?;
8083 let config = CheckoutConfig::read(cwd)?;
8084 for (path, entry) in tracked {
8085 let Some(worktree) = entry.worktree else {
8086 return Ok(true);
8087 };
8088 let attributes = effective.get(&path).ok_or_else(|| {
8089 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8090 })?;
8091 if path_has_ambiguous_transform(&config, attributes)? {
8092 return Ok(true);
8093 }
8094 let symlink_file = entry.index_mode == "120000"
8095 && worktree.mode == "100644"
8096 && worktree.raw_oid == entry.index_oid
8097 && config.symlinks == Some(false);
8098 if worktree.mode != entry.index_mode && !symlink_file {
8099 return Ok(true);
8100 }
8101 if entry.index_mode == "120000" {
8102 if worktree.raw_oid != entry.index_oid {
8103 return Ok(true);
8104 }
8105 continue;
8106 }
8107 #[cfg(unix)]
8108 {
8109 let expected = if entry.index_mode == "100755" {
8110 0o755
8111 } else {
8112 0o644
8113 };
8114 if worktree.permissions != expected {
8115 return Ok(true);
8116 }
8117 }
8118 if allows_expected_crlf(&config, attributes)? {
8119 let (normalized, every_lf_was_crlf) =
8120 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8121 if !every_lf_was_crlf || normalized != entry.index_oid {
8122 return Ok(true);
8123 }
8124 } else if worktree.raw_oid != entry.index_oid {
8125 return Ok(true);
8126 }
8127 }
8128 } else {
8129 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8130 if !run_git_bytes(cwd, &args)?.is_empty() {
8131 return Ok(true);
8132 }
8133 }
8134 for link in gitlinks(cwd)? {
8135 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8136 continue;
8137 };
8138 if include_ignored {
8143 return Ok(true);
8144 }
8145 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8146 if head.trim() != link.oid {
8147 return Ok(true);
8148 }
8149 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8150 {
8151 return Ok(true);
8152 }
8153 }
8154 Ok(false)
8155}
8156
8157pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8158 repository_has_recoverable_work(cwd, false)
8159}
8160
8161fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8162 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8163 Ok(!run_git_bytes(cwd, &args)?.is_empty())
8164}
8165
8166#[cfg(unix)]
8167fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8168 use std::os::unix::ffi::OsStringExt;
8169 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8170}
8171
8172#[cfg(not(unix))]
8173fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8174 String::from_utf8(raw.to_vec())
8175 .map(PathBuf::from)
8176 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8177}
8178
8179fn vanished_untracked_file() -> UntrackedFile {
8195 UntrackedFile {
8196 kind: 0,
8197 len: 0,
8198 modified: None,
8199 created: None,
8200 readonly: false,
8201 symlink_target: None,
8202 #[cfg(unix)]
8203 device: 0,
8204 #[cfg(unix)]
8205 inode: 0,
8206 #[cfg(unix)]
8207 mode: 0,
8208 #[cfg(unix)]
8209 change_seconds: 0,
8210 #[cfg(unix)]
8211 change_nanoseconds: 0,
8212 }
8213}
8214
8215fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8216 let metadata = match std::fs::symlink_metadata(path) {
8217 Ok(metadata) => metadata,
8218 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8219 Err(e) => {
8220 return Err(spar_err!(
8221 "could not inspect the nested repository at {}: {e}",
8222 path.display()
8223 ))
8224 }
8225 };
8226 if !metadata.is_dir() {
8227 bail!(
8228 "git reported {} as a nested repository, but it is not a directory",
8229 path.display()
8230 );
8231 }
8232 if std::fs::symlink_metadata(path.join(".git")).is_err() {
8233 bail!(
8234 "git reported {} as a nested repository, but it has no Git entry",
8235 path.display()
8236 );
8237 }
8238 #[cfg(unix)]
8239 {
8240 use std::os::unix::fs::MetadataExt;
8241 Ok(UntrackedFile {
8242 kind: 3,
8243 len: 0,
8244 modified: None,
8245 created: metadata.created().ok(),
8246 readonly: metadata.permissions().readonly(),
8247 symlink_target: None,
8248 device: metadata.dev(),
8249 inode: metadata.ino(),
8250 mode: metadata.mode(),
8251 change_seconds: 0,
8252 change_nanoseconds: 0,
8253 })
8254 }
8255 #[cfg(not(unix))]
8256 {
8257 Ok(UntrackedFile {
8258 kind: 3,
8259 len: 0,
8260 modified: None,
8261 created: metadata.created().ok(),
8262 readonly: metadata.permissions().readonly(),
8263 symlink_target: None,
8264 })
8265 }
8266}
8267
8268fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8269 let metadata = match std::fs::symlink_metadata(path) {
8270 Ok(metadata) => metadata,
8271 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8272 Err(e) => {
8273 return Err(spar_err!(
8274 "could not inspect untracked file {}: {e}",
8275 path.display()
8276 ))
8277 }
8278 };
8279 let kind = if metadata.file_type().is_symlink() {
8280 2
8281 } else if metadata.is_file() {
8282 1
8283 } else {
8284 bail!(
8285 "untracked path {} is not a regular file or symlink",
8286 path.display()
8287 );
8288 };
8289 let symlink_target = if kind == 2 {
8290 match std::fs::read_link(path) {
8291 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8292 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8293 return Ok(vanished_untracked_file())
8294 }
8295 Err(e) => {
8296 return Err(spar_err!(
8297 "could not read untracked symlink {}: {e}",
8298 path.display()
8299 ))
8300 }
8301 }
8302 } else {
8303 None
8304 };
8305 #[cfg(unix)]
8306 {
8307 use std::os::unix::fs::MetadataExt;
8308 Ok(UntrackedFile {
8309 kind,
8310 len: metadata.len(),
8311 modified: metadata.modified().ok(),
8312 created: metadata.created().ok(),
8313 readonly: metadata.permissions().readonly(),
8314 symlink_target,
8315 device: metadata.dev(),
8316 inode: metadata.ino(),
8317 mode: metadata.mode(),
8318 change_seconds: metadata.ctime(),
8319 change_nanoseconds: metadata.ctime_nsec(),
8320 })
8321 }
8322 #[cfg(not(unix))]
8323 {
8324 Ok(UntrackedFile {
8325 kind,
8326 len: metadata.len(),
8327 modified: metadata.modified().ok(),
8328 created: metadata.created().ok(),
8329 readonly: metadata.permissions().readonly(),
8330 symlink_target,
8331 })
8332 }
8333}
8334
8335#[cfg(unix)]
8336fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8337 use std::os::unix::ffi::OsStrExt;
8338 Ok(value.as_bytes().to_vec())
8339}
8340
8341#[cfg(not(unix))]
8342fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8343 value
8344 .to_str()
8345 .map(|value| value.as_bytes().to_vec())
8346 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8347}
8348
8349#[cfg(unix)]
8350fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8351 use std::os::unix::fs::MetadataExt;
8352 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8353}
8354
8355#[cfg(not(unix))]
8356fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8357 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8358}
8359
8360#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8361pub struct BranchRecord {
8362 pub kind: String,
8363 pub number: i64,
8364}
8365
8366pub fn review_ref(number: i64) -> String {
8369 format!("refs/spar/pr-{number}")
8370}
8371
8372pub fn is_finished(state: &str) -> bool {
8373 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8374}
8375
8376pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8383 if let Some(parent) = path.parent() {
8384 std::fs::create_dir_all(parent)
8385 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8386 }
8387 let tmp = path.with_extension(format!(
8390 "{}.tmp",
8391 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8392 ));
8393 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8394 std::fs::rename(&tmp, path)
8395 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8396 Ok(())
8397}
8398
8399pub fn has_heading(text: &str, title: &str) -> bool {
8406 let wanted = title.trim().to_lowercase();
8407 if wanted.is_empty() {
8408 return false;
8409 }
8410 text.lines()
8411 .filter_map(|line| line.trim().strip_prefix("## "))
8412 .any(|heading| heading.trim().to_lowercase() == wanted)
8413}
8414
8415pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8418 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8419}
8420
8421#[derive(Deserialize)]
8423#[serde(rename_all = "camelCase")]
8424pub struct LinkedPrRow {
8425 number: i64,
8426 #[serde(default)]
8427 url: String,
8428 #[serde(default)]
8429 title: String,
8430 #[serde(default)]
8431 closing_issues_references: Vec<IssueRef>,
8432}
8433
8434pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8441 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8442}
8443
8444pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8446 rows.iter()
8447 .find(|row| {
8448 row.closing_issues_references
8449 .iter()
8450 .any(|linked| linked.number == issue)
8451 })
8452 .map(|row| PrRef {
8453 number: row.number,
8454 url: row.url.clone(),
8455 title: row.title.clone(),
8456 })
8457}
8458
8459pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8461 pick_linked_pr(&linked_pr_rows(json)?, issue)
8462}
8463
8464pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8471 if text.trim().is_empty() {
8472 return Err(spar_err!("GitHub returned no comment data"));
8473 }
8474 let mut out = Vec::new();
8475 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8476 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8477 Value::Array(items) => out.extend(items),
8478 _ => return Err(spar_err!("unexpected non-array comment page")),
8479 }
8480 }
8481 Ok(out)
8482}
8483
8484pub fn parse_comment_pages(text: &str) -> Vec<Value> {
8485 let mut out = Vec::new();
8486 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8487 match value {
8488 Ok(Value::Array(items)) => out.extend(items),
8489 Ok(other) => out.push(other),
8490 Err(_) => break,
8491 }
8492 }
8493 out
8494}
8495
8496pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
8504 body.trim_start().starts_with(STATE_MARKER)
8505 && !viewer.trim().is_empty()
8506 && author.trim().eq_ignore_ascii_case(viewer.trim())
8507 && parse_state_comment(body).is_some()
8508}
8509
8510pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
8513 let marker = body.find(STATE_MARKER)?;
8514 let start = body[marker..].find('{')? + marker;
8515 let end = body.rfind('}')?;
8516 if end <= start {
8517 return None;
8518 }
8519 match serde_json::from_str(&body[start..=end]) {
8520 Ok(state) => Some(state),
8521 Err(_) => {
8522 logdim!("found a spar state comment but could not parse it");
8523 None
8524 }
8525 }
8526}
8527
8528fn choose_state_for_head(
8529 candidates: Vec<PersistedState>,
8530 actual_head: &str,
8531) -> Option<PersistedState> {
8532 let matching: Vec<PersistedState> = candidates
8533 .iter()
8534 .filter(|state| state.pr_head == actual_head)
8535 .cloned()
8536 .collect();
8537 if !matching.is_empty() {
8538 return newest_state(matching);
8539 }
8540 newest_state(candidates)
8541}
8542
8543fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
8544 candidates.into_iter().reduce(|best, candidate| {
8545 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
8546 candidate
8547 } else {
8548 best
8553 }
8554 })
8555}
8556
8557pub fn self_binary() -> Result<PathBuf> {
8563 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
8564 let path = PathBuf::from(path);
8565 if proc::is_executable(&path) {
8566 return Ok(path);
8567 }
8568 bail!(
8569 "SPAR_SELF_BIN is set to {}, which is not executable",
8570 path.display()
8571 );
8572 }
8573 std::env::current_exe()
8574 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8575}
8576
8577fn bool_env(value: bool) -> &'static str {
8578 if value {
8579 "1"
8580 } else {
8581 "0"
8582 }
8583}
8584
8585pub fn sh_quote(text: &str) -> String {
8588 format!("'{}'", text.replace('\'', r"'\''"))
8589}
8590
8591pub fn style_from_env() -> Style {
8594 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8595 Style {
8596 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8597 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8598 ..Style::permissive()
8599 }
8600}
8601
8602#[cfg(test)]
8603mod tests {
8604 use super::*;
8605 use crate::config::StateStore;
8606 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8607 use std::process::Command;
8608
8609 fn repo_for_titles() -> Repo {
8610 Repo {
8611 root: PathBuf::from("/nonexistent"),
8612 style: Style::default(),
8613 branch_prefix: String::new(),
8614 state_store: StateStore::Local,
8615 followups: crate::config::Followups::Issues,
8616 drafts: Drafts::Never,
8617 fork_when_denied: true,
8618 fork: OnceLock::new(),
8619 viewer: OnceLock::new(),
8620 checkpoints: Mutex::new(BTreeMap::new()),
8621 writes: WriteStats::default(),
8622 }
8623 }
8624
8625 #[test]
8626 fn write_results_accumulate_for_the_run() {
8627 let repo = repo_for_titles();
8628
8629 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8630 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8631
8632 assert_eq!(
8633 WriteSummary {
8634 attempted: 2,
8635 failed: 1,
8636 },
8637 repo.write_summary()
8638 );
8639 }
8640
8641 #[test]
8642 fn only_failed_write_preflights_join_the_summary() {
8643 let repo = repo_for_titles();
8644
8645 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8646 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8647
8648 assert_eq!(
8649 WriteSummary {
8650 attempted: 1,
8651 failed: 1,
8652 },
8653 repo.write_summary()
8654 );
8655 }
8656
8657 #[test]
8658 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8659 let repo = repo_for_titles();
8660
8661 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8662 assert_eq!(
8663 WriteSummary {
8664 attempted: 1,
8665 failed: 1,
8666 },
8667 repo.write_summary()
8668 );
8669 }
8670
8671 #[test]
8672 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8673 let mut repo = repo_for_titles();
8674 repo.followups = Followups::Local;
8675
8676 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8677 assert_eq!(WriteSummary::default(), repo.write_summary());
8678 }
8679
8680 #[test]
8681 fn a_failed_remote_state_read_stops_before_state_mutation() {
8682 let root = std::env::temp_dir().join(format!(
8683 "spar-state-preflight-{}-{}",
8684 std::process::id(),
8685 std::time::SystemTime::now()
8686 .duration_since(std::time::UNIX_EPOCH)
8687 .unwrap()
8688 .as_nanos()
8689 ));
8690 std::fs::create_dir_all(&root).unwrap();
8691 let _fixture = ReviewFixture { root: root.clone() };
8692 let mut repo = repo_for_titles();
8693 repo.root = root;
8694 repo.state_store = StateStore::Both;
8695 let state = PersistedState {
8696 version: 1,
8697 checkpoint: 4,
8698 round: 2,
8699 next_actor: "a".into(),
8700 status: Status::Pending,
8701 pr_head: "abc123".into(),
8702 ledger: Ledger::new(),
8703 filed: Vec::new(),
8704 open_findings: Vec::new(),
8705 disputes: Vec::new(),
8706 noted: Vec::new(),
8707 };
8708
8709 let error = repo
8710 .write_state_after_remote_read(
8711 7,
8712 &state,
8713 Err(crate::error::SparError::new("state comments unavailable")),
8714 )
8715 .unwrap_err();
8716
8717 assert!(error.to_string().contains("state comments unavailable"));
8718 assert!(!repo.state_path(7).exists());
8719 assert_eq!(0, repo.remembered_checkpoint(7));
8720 assert_eq!(
8721 WriteSummary {
8722 attempted: 1,
8723 failed: 1,
8724 },
8725 repo.write_summary()
8726 );
8727 }
8728
8729 #[test]
8730 fn only_known_build_and_cache_directories_are_generated_artifacts() {
8731 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
8732 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
8733 assert!(is_generated_artifact(Path::new(
8734 "package/node_modules/dependency/file.js"
8735 )));
8736 assert!(!is_generated_artifact(Path::new(
8737 "distribution/required-package.js"
8738 )));
8739 assert!(!is_generated_artifact(Path::new(
8740 "generated/required-fixture.txt"
8741 )));
8742 assert!(!is_generated_artifact(Path::new("local.env")));
8743 }
8744
8745 #[test]
8746 fn only_finder_state_files_are_desktop_metadata() {
8747 assert!(is_desktop_metadata(Path::new(".DS_Store")));
8748 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
8749 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
8750 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
8751 }
8752
8753 struct ReviewFixture {
8754 root: PathBuf,
8755 }
8756
8757 impl Drop for ReviewFixture {
8758 fn drop(&mut self) {
8759 let _ = std::fs::remove_dir_all(&self.root);
8760 }
8761 }
8762
8763 fn test_git(cwd: &Path, args: &[&str]) -> String {
8764 let output = Command::new("git")
8765 .args(args)
8766 .current_dir(cwd)
8767 .output()
8768 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
8769 assert!(
8770 output.status.success(),
8771 "git {args:?} failed: {}",
8772 String::from_utf8_lossy(&output.stderr)
8773 );
8774 String::from_utf8_lossy(&output.stdout).into_owned()
8775 }
8776
8777 fn review_fixture(
8778 tag: &str,
8779 number: i64,
8780 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
8781 use std::sync::atomic::{AtomicU32, Ordering};
8782 static NEXT: AtomicU32 = AtomicU32::new(0);
8783 let id = NEXT.fetch_add(1, Ordering::Relaxed);
8784 let root =
8785 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
8786 let origin = root.join("origin.git");
8787 let work = root.join("work");
8788 std::fs::create_dir_all(&origin).unwrap();
8789 std::fs::create_dir_all(&work).unwrap();
8790 test_git(&origin, &["init", "--bare", "-b", "main"]);
8791 test_git(&work, &["init", "-b", "main"]);
8792 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
8793 test_git(&work, &["config", "user.name", "spar test"]);
8794 test_git(&work, &["config", "commit.gpgsign", "false"]);
8795 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
8796 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
8797 std::fs::write(work.join("README.md"), "seed\n").unwrap();
8798 std::fs::write(work.join("data.txt"), "old\n").unwrap();
8799 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
8800 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
8801 test_git(&work, &["add", "."]);
8802 test_git(&work, &["commit", "-m", "seed"]);
8803 test_git(
8804 &work,
8805 &["remote", "add", "origin", origin.to_str().unwrap()],
8806 );
8807 test_git(&work, &["push", "-u", "origin", "main"]);
8808 test_git(
8809 &work,
8810 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
8811 );
8812 let cfg = crate::config::parse(
8813 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
8814 )
8815 .unwrap();
8816 let repo = Repo::open(&work, &cfg).unwrap();
8817 let path = repo.worktree_for_pr_head(number).unwrap();
8818 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8819 (ReviewFixture { root }, repo, path, checkpoint)
8820 }
8821
8822 #[test]
8823 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
8824 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
8825
8826 repo.release_review_worktree_checked(901, &checkpoint)
8827 .unwrap();
8828
8829 assert!(!path.exists());
8830 }
8831
8832 #[test]
8833 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
8834 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
8835 let work = fixture.root.join("work");
8836 let seed = test_git(&work, &["rev-parse", "HEAD"]);
8837 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
8838 let publish = |message: &str| {
8841 let oid = test_git(
8842 &work,
8843 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
8844 );
8845 let oid = oid.trim().to_string();
8846 test_git(
8847 &work,
8848 &[
8849 "push",
8850 "--force",
8851 "origin",
8852 &format!("{oid}:refs/pull/923/head"),
8853 ],
8854 );
8855 oid
8856 };
8857 let reviewed = publish("reviewed head");
8858 repo.worktree_for_pr_head(923).unwrap();
8859 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
8860
8861 let rewritten = publish("rewritten head");
8862 repo.worktree_for_pr_head(923).unwrap();
8863
8864 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
8865 }
8866
8867 #[test]
8868 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
8869 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
8870 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
8871 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
8872 test_git(&path, &["add", "recovery.txt"]);
8873 test_git(&path, &["commit", "-m", "recovery commit"]);
8874 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
8875 test_git(&path, &["reset", "--hard", "main"]);
8876
8877 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
8878 test_git(
8879 &path,
8880 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8881 );
8882 }
8883
8884 #[test]
8885 fn a_review_ref_reflog_only_commit_prevents_deletion() {
8886 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
8887 let local_ref = review_ref(921);
8888 let original = test_git(&path, &["rev-parse", &local_ref]);
8889 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
8890 let recovery = test_git(
8891 &path,
8892 &[
8893 "commit-tree",
8894 tree.trim(),
8895 "-p",
8896 original.trim(),
8897 "-m",
8898 "review ref recovery",
8899 ],
8900 );
8901 test_git(
8902 &path,
8903 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
8904 );
8905 test_git(
8906 &path,
8907 &["update-ref", &local_ref, original.trim(), recovery.trim()],
8908 );
8909
8910 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
8911 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
8912 test_git(
8913 &path,
8914 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8915 );
8916 }
8917
8918 #[test]
8919 fn an_unpublished_commit_message_draft_is_recoverable() {
8920 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
8921 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
8922 let git_dir = if raw.is_absolute() {
8923 raw
8924 } else {
8925 path.join(raw)
8926 };
8927 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
8928
8929 assert!(repository_has_recoverable_work(&path, true).unwrap());
8930 assert_eq!(
8931 "unique recovery draft\n",
8932 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
8933 );
8934 }
8935
8936 #[test]
8937 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
8938 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
8939 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8940
8941 let error = repo
8942 .release_review_worktree_checked(902, &checkpoint)
8943 .unwrap_err();
8944
8945 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8946 assert!(error.to_string().contains("kept for recovery"), "{error}");
8947 assert_eq!(
8948 "recover me\n",
8949 std::fs::read_to_string(path.join("README.md")).unwrap()
8950 );
8951 repo.release_review_worktree(902);
8952 }
8953
8954 #[test]
8955 fn a_review_commit_is_retained_after_a_checked_read() {
8956 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
8957 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
8958 test_git(&path, &["add", "review-note.txt"]);
8959 test_git(&path, &["commit", "-m", "local review recovery"]);
8960 let head = test_git(&path, &["rev-parse", "HEAD"]);
8961
8962 let error = repo
8963 .release_review_worktree_checked(903, &checkpoint)
8964 .unwrap_err();
8965
8966 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8967 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
8968 assert_eq!(
8969 "recover me\n",
8970 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
8971 );
8972 repo.release_review_worktree(903);
8973 }
8974
8975 #[test]
8979 fn an_ignored_review_file_is_retained_after_a_checked_read() {
8980 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
8981 std::fs::create_dir_all(path.join("generated")).unwrap();
8982 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
8983
8984 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8985 .expect("its own output is not a change");
8986 let error = repo
8987 .release_review_worktree_checked(904, &checkpoint)
8988 .unwrap_err();
8989
8990 assert!(error.to_string().contains("recoverable"), "{error}");
8991 assert_eq!(
8992 "recover me\n",
8993 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
8994 );
8995 repo.release_review_worktree(904);
8996 }
8997
8998 #[test]
8999 fn a_preexisting_ignored_review_file_change_is_retained() {
9005 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9006 std::fs::create_dir_all(path.join("generated")).unwrap();
9007 let ignored = path.join("generated/recovery.txt");
9008 std::fs::write(&ignored, "before\n").unwrap();
9009 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9010 std::fs::write(&ignored, "after!\n").unwrap();
9011
9012 let error = repo
9013 .release_review_worktree_checked(905, &checkpoint)
9014 .unwrap_err();
9015
9016 assert!(error.to_string().contains("recoverable"), "{error}");
9017 assert!(path.exists());
9018 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9019 repo.release_review_worktree(905);
9020 }
9021
9022 #[test]
9023 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9024 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9025 std::fs::create_dir_all(path.join("generated")).unwrap();
9026 let ignored = path.join("generated/recovery.txt");
9027 std::fs::write(&ignored, "keep me\n").unwrap();
9028 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9029
9030 let error = repo
9031 .release_review_worktree_checked(906, &checkpoint)
9032 .unwrap_err();
9033
9034 assert!(error.to_string().contains("recoverable"), "{error}");
9035 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9036 }
9037
9038 #[test]
9039 fn overwriting_a_preexisting_untracked_file_is_detected() {
9040 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9041 let untracked = path.join("notes.txt");
9042 std::fs::write(&untracked, "before\n").unwrap();
9043 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9044 std::fs::write(&untracked, "after!\n").unwrap();
9045
9046 let error = repo
9047 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9048 .unwrap_err();
9049
9050 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9051 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9052 }
9053
9054 #[test]
9055 fn an_assume_unchanged_edit_is_detected() {
9056 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9057 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9058 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9059
9060 let error = repo
9061 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9062 .unwrap_err();
9063
9064 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9065 assert_eq!(
9066 "hidden\n",
9067 std::fs::read_to_string(path.join("README.md")).unwrap()
9068 );
9069 }
9070
9071 #[test]
9072 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9073 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9074 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9075 test_git(&path, &["add", "README.md"]);
9076 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9077
9078 let error = repo
9079 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9080 .unwrap_err();
9081
9082 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9083 assert_eq!(
9084 b"seed\r\n",
9085 std::fs::read(path.join("README.md")).unwrap().as_slice()
9086 );
9087 }
9088
9089 #[cfg(unix)]
9090 #[test]
9091 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9092 use std::os::unix::fs::PermissionsExt;
9093
9094 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9095 test_git(&path, &["config", "core.filemode", "false"]);
9096 let readme = path.join("README.md");
9097 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9098 permissions.set_mode(0o755);
9099 std::fs::set_permissions(&readme, permissions).unwrap();
9100 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9101
9102 let error = repo
9103 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9104 .unwrap_err();
9105
9106 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9107 assert_eq!(
9108 0o755,
9109 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9110 );
9111 }
9112
9113 #[test]
9114 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9115 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9116 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9117 test_git(&path, &["add", ".gitattributes"]);
9118 test_git(&path, &["commit", "-m", "select data filter"]);
9119 let baseline = repo.worktree_baseline(&path).unwrap();
9120 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9121
9122 assert!(repo
9123 .commit_pending_changes(&path, &baseline, "change data", "change data")
9124 .unwrap());
9125 let error = repo
9126 .refuse_unrepresented_tracked_changes(&path, &baseline)
9127 .unwrap_err();
9128
9129 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9130 assert_eq!(
9131 "secret: recover me\nnew\n",
9132 std::fs::read_to_string(path.join("data.txt")).unwrap()
9133 );
9134 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9135 }
9136
9137 #[test]
9138 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9139 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9140 std::fs::create_dir_all(path.join("target")).unwrap();
9141 let untracked = path.join("target/user.yaml");
9142 std::fs::write(&untracked, "user data\n").unwrap();
9143 let baseline = repo.worktree_baseline(&path).unwrap();
9144 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9145
9146 assert!(repo
9147 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9148 .unwrap());
9149
9150 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9151 assert_eq!(
9152 "?? target/user.yaml\n",
9153 test_git(&path, &["status", "--short", "--untracked-files=all"])
9154 );
9155 assert!(test_git(
9156 &path,
9157 &[
9158 "ls-tree",
9159 "-r",
9160 "--name-only",
9161 "HEAD",
9162 "--",
9163 "target/user.yaml"
9164 ]
9165 )
9166 .is_empty());
9167 }
9168
9169 #[test]
9170 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9171 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9172 std::fs::create_dir_all(path.join("target")).unwrap();
9173 let untracked = path.join("target/user.yaml");
9174 std::fs::write(&untracked, "before\n").unwrap();
9175 let baseline = repo.worktree_baseline(&path).unwrap();
9176 let before = test_git(&path, &["rev-parse", "HEAD"]);
9177 std::fs::write(&untracked, "after\n").unwrap();
9178 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9179
9180 let error = repo
9181 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9182 .unwrap_err();
9183
9184 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9185 assert!(error.to_string().contains("target/user.yaml"), "{error}");
9186 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9187 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9188 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9189 }
9190
9191 #[test]
9192 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9193 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9194 let baseline = repo.worktree_baseline(&path).unwrap();
9195 std::fs::create_dir_all(path.join("target")).unwrap();
9196 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9197
9198 assert!(repo
9199 .commit_pending_changes(&path, &baseline, "add file", "add file")
9200 .unwrap());
9201
9202 assert_eq!(
9203 "new file\n",
9204 test_git(&path, &["show", "HEAD:target/new.txt"])
9205 );
9206 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9207 }
9208
9209 #[test]
9210 fn a_managed_commit_accepts_a_custom_output_rebuild() {
9211 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9212 exclude_paths(&repo, &["manager/public/"]);
9213 let output = path.join("manager/public/assets");
9214 std::fs::create_dir_all(&output).unwrap();
9215 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9216 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9217 let baseline = repo.worktree_baseline(&path).unwrap();
9218 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9219 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9220 std::fs::remove_file(output.join("old.js")).unwrap();
9221 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9222 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9223
9224 assert!(repo
9225 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9226 .unwrap());
9227 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9228 repo.refuse_changed_existing_untracked(&path, &baseline)
9229 .unwrap();
9230 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9231 .unwrap();
9232
9233 assert_eq!(
9234 "tracked change\n",
9235 test_git(&path, &["show", "HEAD:README.md"])
9236 );
9237 assert_eq!(
9238 "new source\n",
9239 test_git(&path, &["show", "HEAD:new-source.txt"])
9240 );
9241 assert!(test_git(
9242 &path,
9243 &[
9244 "ls-tree",
9245 "-r",
9246 "--name-only",
9247 "HEAD",
9248 "--",
9249 "manager/public"
9250 ]
9251 )
9252 .is_empty());
9253 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9254 assert!(!output.join("old.js").exists());
9255 assert_eq!(
9256 "new styles\n",
9257 std::fs::read_to_string(output.join("index.css")).unwrap()
9258 );
9259 assert!(repository_has_recoverable_work(&path, true).unwrap());
9260 repo.release_review_worktree(912);
9261 assert!(output.join("index-DUym-Rfj.js").exists());
9262 }
9263
9264 #[test]
9265 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9266 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9267 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9268 let baseline = repo.worktree_baseline(&path).unwrap();
9269 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9270 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9271
9272 let error = repo
9273 .commit_pending_changes(&path, &baseline, "change", "change")
9274 .unwrap_err();
9275
9276 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9277 assert!(error.to_string().contains("notes.txt"), "{error}");
9278 }
9279
9280 #[test]
9281 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9282 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9283 let baseline = repo.worktree_baseline(&path).unwrap();
9284 let nested = path.join("generated/repro");
9285 std::fs::create_dir_all(&nested).unwrap();
9286 test_git(&nested, &["init"]);
9287 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9288
9289 assert!(repo
9290 .commit_pending_changes(&path, &baseline, "change", "change")
9291 .unwrap());
9292 assert!(!repo.has_uncommitted_changes(&path).unwrap());
9293 assert!(repository_has_recoverable_work(&path, true).unwrap());
9294 repo.release_review_worktree(927);
9295 assert!(nested.join(".git").exists());
9296 }
9297
9298 #[test]
9299 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9300 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9301 test_git(&path, &["config", "core.autocrlf", "true"]);
9302 std::fs::write(path.join("README.md"), "seed\n").unwrap();
9303 assert_eq!(
9304 test_git(&path, &["hash-object", "README.md"]).trim(),
9305 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9306 );
9307
9308 assert!(repository_has_recoverable_work(&path, true).unwrap());
9309 assert_eq!(
9310 "seed\n",
9311 std::fs::read_to_string(path.join("README.md")).unwrap()
9312 );
9313 }
9314
9315 #[test]
9316 fn autocrlf_input_overrides_a_crlf_core_eol() {
9317 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9318 test_git(&path, &["config", "core.autocrlf", "input"]);
9319 test_git(&path, &["config", "core.eol", "crlf"]);
9320 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9321 assert_eq!(
9322 test_git(&path, &["hash-object", "README.md"]).trim(),
9323 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9324 );
9325
9326 assert!(repository_has_recoverable_work(&path, true).unwrap());
9327 assert_eq!(
9328 b"seed\r\n",
9329 std::fs::read(path.join("README.md")).unwrap().as_slice()
9330 );
9331 }
9332
9333 #[cfg(unix)]
9334 #[test]
9335 fn a_non_executable_permission_change_is_recoverable() {
9336 use std::os::unix::fs::PermissionsExt;
9337
9338 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9339 let readme = path.join("README.md");
9340 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9341 permissions.set_mode(0o600);
9342 std::fs::set_permissions(&readme, permissions).unwrap();
9343 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9344
9345 let error = repo
9346 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9347 .unwrap_err();
9348
9349 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9350 assert!(repository_has_recoverable_work(&path, true).unwrap());
9351 assert_eq!(
9352 0o600,
9353 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9354 );
9355 }
9356
9357 #[cfg(unix)]
9358 #[test]
9359 fn a_managed_commit_skips_signing_and_hooks() {
9360 use std::os::unix::fs::PermissionsExt;
9361
9362 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9363 let common = common_git_dir(&path).unwrap();
9364 let hook = common.join("hooks/pre-commit");
9365 let marker = fixture.root.join("hook-ran");
9366 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9367 std::fs::write(
9368 &hook,
9369 format!(
9370 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9371 sh_quote(marker.to_str().unwrap())
9372 ),
9373 )
9374 .unwrap();
9375 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9376 permissions.set_mode(0o755);
9377 std::fs::set_permissions(&hook, permissions).unwrap();
9378 test_git(&path, &["config", "commit.gpgsign", "true"]);
9379 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9380 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9381 test_git(&path, &["add", "managed.txt"]);
9382
9383 repo.commit_staged_changes(&path, "record managed change")
9384 .unwrap();
9385
9386 assert!(!marker.exists());
9387 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9388 }
9389
9390 #[test]
9391 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9392 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9393 std::fs::write(
9394 path.join(".gitattributes"),
9395 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
9396 )
9397 .unwrap();
9398 test_git(&path, &["add", ".gitattributes"]);
9399 test_git(&path, &["commit", "-m", "select automatic text"]);
9400 test_git(&path, &["config", "core.autocrlf", "true"]);
9401
9402 assert!(repository_has_recoverable_work(&path, true).unwrap());
9403 }
9404
9405 #[test]
9406 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
9407 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
9408 std::fs::write(
9409 path.join(".gitattributes"),
9410 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
9411 )
9412 .unwrap();
9413 test_git(&path, &["add", ".gitattributes"]);
9414 test_git(&path, &["commit", "-m", "select ident expansion"]);
9415 std::fs::write(path.join("README.md"), "seed\n").unwrap();
9416
9417 assert!(repository_has_recoverable_work(&path, true).unwrap());
9418 }
9419
9420 #[test]
9426 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
9427 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
9428 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
9429 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9430 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
9431 std::fs::write(
9432 path.join("bindings/uniffi-bindgen/Cargo.lock"),
9433 "# generated\n",
9434 )
9435 .unwrap();
9436
9437 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9438 .expect("a path the project ignores is the call's own output");
9439 }
9440
9441 #[test]
9442 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
9443 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
9444 exclude_paths(&repo, &["target/"]);
9445 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9446 std::fs::create_dir_all(path.join("target/package")).unwrap();
9447 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
9448
9449 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9450 .expect("an ignored attribute file governs only ignored paths");
9451 assert!(
9452 !repo.has_uncommitted_changes(&path).unwrap(),
9453 "ignored dependency attributes must not stop the next review"
9454 );
9455 }
9456
9457 #[test]
9458 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
9459 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
9460 std::fs::create_dir_all(path.join("source")).unwrap();
9461 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
9462
9463 assert!(repo.has_uncommitted_changes(&path).unwrap());
9464 }
9465
9466 #[test]
9467 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
9468 for source in ["repository", "nested", "local", "global"] {
9469 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
9470 std::fs::create_dir_all(path.join("manager")).unwrap();
9471 match source {
9472 "repository" => {
9473 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
9474 .unwrap();
9475 test_git(&path, &["add", ".gitignore"]);
9476 test_git(&path, &["commit", "-m", "ignore artifacts"]);
9477 }
9478 "nested" => {
9479 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
9480 test_git(&path, &["add", "manager/.gitignore"]);
9481 test_git(&path, &["commit", "-m", "ignore artifacts"]);
9482 }
9483 "local" => exclude_paths(&repo, &["manager/scratch/"]),
9484 "global" => {
9485 let excludes = repo.root().parent().unwrap().join("global-ignore");
9486 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
9487 test_git(
9488 &path,
9489 &["config", "core.excludesFile", excludes.to_str().unwrap()],
9490 );
9491 }
9492 _ => unreachable!(),
9493 }
9494 let output = path.join("manager/scratch");
9495 std::fs::create_dir_all(&output).unwrap();
9496 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
9497 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
9498 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9499 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
9500 std::fs::remove_file(output.join(".DS_Store")).unwrap();
9501 for file in [
9502 ".gitattributes",
9503 "Cargo.lock",
9504 "font.woff2",
9505 "bundle.js",
9506 "cache.db",
9507 ] {
9508 std::fs::write(output.join(file), "generated\n").unwrap();
9509 }
9510 #[cfg(unix)]
9511 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
9512 let nested = output.join("dependency");
9513 std::fs::create_dir_all(&nested).unwrap();
9514 test_git(&nested, &["init"]);
9515 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9516 .unwrap_or_else(|error| panic!("{source}: {error}"));
9517 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9518
9519 let baseline = repo.worktree_baseline(&path).unwrap();
9520 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
9521 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
9522 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
9523 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
9524 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
9525 assert!(repo
9526 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
9527 .unwrap_or_else(|error| panic!("{source}: {error}")));
9528 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9529 assert!(test_git(
9530 &path,
9531 &[
9532 "ls-tree",
9533 "-r",
9534 "--name-only",
9535 "HEAD",
9536 "--",
9537 "manager/scratch"
9538 ]
9539 )
9540 .is_empty());
9541 assert!(
9542 repository_has_recoverable_work(&path, true).unwrap(),
9543 "{source}"
9544 );
9545 repo.release_review_worktree(945);
9546 assert_eq!(
9547 "LOCAL=updated\n",
9548 std::fs::read_to_string(output.join(".env.local")).unwrap()
9549 );
9550 assert!(nested.join(".git").exists(), "{source}");
9551 }
9552 }
9553
9554 #[test]
9558 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
9559 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
9560 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9561 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
9562
9563 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9564 .expect("spar's own marker is not something a call did");
9565 }
9566
9567 #[test]
9568 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
9569 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
9570
9571 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9572 .expect("a path a build removed is not an unreadable worktree");
9573
9574 assert_eq!(vanished_untracked_file(), gone);
9575 }
9576
9577 #[test]
9580 fn a_vanished_path_is_not_recorded_as_absent() {
9581 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9582 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9583 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9584 std::fs::remove_file(path.join("notes.txt")).unwrap();
9585
9586 let error = repo
9587 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9588 .unwrap_err();
9589
9590 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9591 }
9592
9593 fn exclude_paths(repo: &Repo, lines: &[&str]) {
9594 use std::io::Write;
9595 let path = repo.root().join(".git").join("info").join("exclude");
9596 let mut file = std::fs::OpenOptions::new()
9597 .create(true)
9598 .append(true)
9599 .open(&path)
9600 .unwrap();
9601 for line in lines {
9602 writeln!(file, "{line}").unwrap();
9603 }
9604 }
9605
9606 #[test]
9607 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9608 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9609 exclude_paths(&repo, &["dist/"]);
9610 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9611 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9612 let baseline = repo.worktree_baseline(&path).unwrap();
9613 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9614 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9615 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9616 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9617
9618 assert!(repo
9619 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9620 .unwrap());
9621 assert_eq!(
9622 "tracked change\n",
9623 std::fs::read_to_string(path.join("README.md")).unwrap()
9624 );
9625 }
9626
9627 #[test]
9628 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
9629 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
9630 exclude_paths(&repo, &["dist/"]);
9631 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9632 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9633 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
9634 let baseline = repo.worktree_baseline(&path).unwrap();
9635 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9636 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
9637 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9638 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9639
9640 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9641 repo.refuse_changed_existing_untracked(&path, &baseline)
9642 .unwrap();
9643 }
9644
9645 #[test]
9646 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
9647 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
9648 exclude_paths(&repo, &["dist/"]);
9649 std::fs::create_dir_all(path.join("dist")).unwrap();
9650 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9651 let baseline = repo.worktree_baseline(&path).unwrap();
9652 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9653 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9654
9655 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9656 repo.refuse_changed_existing_untracked(&path, &baseline)
9657 .unwrap();
9658 }
9659
9660 #[test]
9669 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
9670 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
9671 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
9672 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
9673 test_git(
9674 repo.root(),
9675 &[
9676 "worktree",
9677 "add",
9678 "-b",
9679 "issue-950",
9680 held.to_str().unwrap(),
9681 "main",
9682 ],
9683 );
9684
9685 let found = repo
9686 .worktree_holding("issue-950")
9687 .expect("the branch is checked out somewhere");
9688
9689 assert!(found.ends_with("issue-950"), "{}", found.display());
9690 assert_eq!(None, repo.worktree_holding("issue-951"));
9691 }
9692
9693 #[test]
9694 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
9695 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
9696 exclude_paths(&repo, &["manager/public/assets/"]);
9697 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
9698 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
9699 std::fs::write(&bundle, "first build\n").unwrap();
9700 let baseline = repo.worktree_baseline(&path).unwrap();
9701 std::fs::write(&bundle, "second build\n").unwrap();
9702 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
9703
9704 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
9705 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9706 }
9707
9708 #[test]
9709 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
9710 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
9711 exclude_paths(&repo, &["dist/"]);
9712 let baseline = repo.worktree_baseline(&path).unwrap();
9713 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
9714
9715 let error = repo
9716 .refuse_read_only_leavings(&path, &baseline)
9717 .unwrap_err();
9718
9719 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9720 assert!(error.to_string().contains("scratch.md"), "{error}");
9721 }
9722
9723 #[test]
9724 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
9725 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
9726 exclude_paths(&repo, &["dist/", ".env.local"]);
9727 std::fs::create_dir_all(path.join("dist")).unwrap();
9728 let baseline = repo.worktree_baseline(&path).unwrap();
9729 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
9730 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
9731
9732 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9733 assert!(repository_has_recoverable_work(&path, true).unwrap());
9734 }
9735
9736 #[test]
9737 fn a_read_only_inspection_may_rebuild_generated_output() {
9738 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
9739 exclude_paths(&repo, &["dist/"]);
9740 std::fs::create_dir_all(path.join("dist")).unwrap();
9741 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9742 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9743 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9744 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9745
9746 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9747 .unwrap();
9748 }
9749
9750 #[test]
9751 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
9752 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
9753 exclude_paths(&repo, &["dist/", ".env.local"]);
9754 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9755 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9756 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
9757
9758 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9759 .unwrap();
9760 }
9761
9762 #[test]
9763 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
9764 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
9765 exclude_paths(&repo, &["dist/", ".env.local"]);
9766 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9767 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9768 std::fs::remove_file(path.join(".env.local")).unwrap();
9769
9770 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9771 .unwrap();
9772 }
9773
9774 #[test]
9775 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
9776 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
9777 exclude_paths(&repo, &["dist/"]);
9778 std::fs::write(path.join("notes.md"), "before\n").unwrap();
9779 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9780 std::fs::write(path.join("notes.md"), "after\n").unwrap();
9781
9782 let error = repo
9783 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9784 .unwrap_err();
9785
9786 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9787 assert!(error.to_string().contains("notes.md"), "{error}");
9788 }
9789
9790 #[test]
9791 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
9792 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
9793 exclude_paths(&repo, &["dist/", "local.env"]);
9794 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
9795 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9796 std::fs::write(
9799 repo.root().join(".git").join("info").join("exclude"),
9800 "dist/\n",
9801 )
9802 .unwrap();
9803
9804 let error = repo
9805 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9806 .unwrap_err();
9807
9808 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9809 assert!(error.to_string().contains("local.env"), "{error}");
9810 }
9811
9812 #[test]
9813 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
9814 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
9815 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9816 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9817
9818 let error = repo
9819 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9820 .unwrap_err();
9821
9822 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9823 assert!(error.to_string().contains("README.md"), "{error}");
9824 }
9825
9826 #[test]
9827 fn build_output_alone_does_not_keep_a_worktree() {
9828 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
9829 exclude_paths(&repo, &["target/", "dist/"]);
9830 std::fs::create_dir_all(path.join("target/debug")).unwrap();
9831 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9832 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
9833 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
9834
9835 assert!(!repository_has_recoverable_work(&path, true).unwrap());
9836 repo.release_review_worktree(933);
9837
9838 assert!(!path.exists());
9839 }
9840
9841 #[test]
9842 fn finder_metadata_alone_does_not_keep_a_worktree() {
9843 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
9844 exclude_paths(&repo, &[".DS_Store"]);
9845 std::fs::create_dir_all(path.join("src")).unwrap();
9846 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
9847 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
9848
9849 assert!(!repository_has_recoverable_work(&path, true).unwrap());
9850 repo.release_review_worktree(937);
9851
9852 assert!(!path.exists());
9853 }
9854
9855 #[test]
9856 fn a_fetch_record_alone_does_not_keep_a_worktree() {
9857 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
9858 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9859 let git_dir = if raw.is_absolute() {
9860 raw
9861 } else {
9862 path.join(raw)
9863 };
9864 let head = test_git(&path, &["rev-parse", "HEAD"]);
9865 std::fs::write(
9866 git_dir.join("FETCH_HEAD"),
9867 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
9868 )
9869 .unwrap();
9870
9871 assert!(!repository_has_recoverable_work(&path, true).unwrap());
9872 repo.release_review_worktree(938);
9873
9874 assert!(!path.exists());
9875 }
9876
9877 #[test]
9878 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
9879 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
9880 exclude_paths(&repo, &["target/", ".env.local"]);
9881 std::fs::create_dir_all(path.join("target/debug")).unwrap();
9882 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9883 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
9884
9885 assert!(repository_has_recoverable_work(&path, true).unwrap());
9886 repo.release_review_worktree(934);
9887
9888 assert_eq!(
9889 "TOKEN=keep me\n",
9890 std::fs::read_to_string(path.join(".env.local")).unwrap()
9891 );
9892 }
9893
9894 #[test]
9895 fn a_repository_nested_in_build_output_keeps_a_worktree() {
9896 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
9897 exclude_paths(&repo, &["node_modules/"]);
9898 let nested = path.join("node_modules/local-dep");
9899 std::fs::create_dir_all(&nested).unwrap();
9900 test_git(&nested, &["init"]);
9901 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
9902
9903 assert!(repository_has_recoverable_work(&path, true).unwrap());
9904 repo.release_review_worktree(935);
9905
9906 assert!(nested.join(".git").exists());
9907 }
9908
9909 #[test]
9910 fn an_ordinary_untracked_file_keeps_a_worktree() {
9911 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
9912 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
9913
9914 assert!(repository_has_recoverable_work(&path, true).unwrap());
9915 repo.release_review_worktree(936);
9916
9917 assert_eq!(
9918 "somebody's notes\n",
9919 std::fs::read_to_string(path.join("notes.md")).unwrap()
9920 );
9921 }
9922
9923 #[test]
9924 fn a_legacy_crlf_checkout_is_retained_conservatively() {
9925 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
9926 std::fs::write(
9927 path.join(".gitattributes"),
9928 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
9929 )
9930 .unwrap();
9931 test_git(&path, &["add", ".gitattributes"]);
9932 test_git(&path, &["commit", "-m", "select legacy line endings"]);
9933
9934 assert!(repository_has_recoverable_work(&path, true).unwrap());
9935 }
9936
9937 #[test]
9938 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
9939 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
9940 let nested = path.join("tracked");
9941 std::fs::create_dir_all(&nested).unwrap();
9942 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
9943 test_git(&path, &["add", "tracked/seed.txt"]);
9944 test_git(&path, &["commit", "-m", "add tracked directory"]);
9945 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9946 test_git(&nested, &["init"]);
9947
9948 let error = repo
9949 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9950 .unwrap_err();
9951
9952 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9953 assert!(error.to_string().contains("Git entry"), "{error}");
9954 assert!(nested.join(".git").exists());
9955 }
9956
9957 #[test]
9962 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
9963 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
9964 exclude_paths(&repo, &["target/"]);
9965 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9966 let repro = path.join("target/pr918-repro");
9967 std::fs::create_dir_all(&repro).unwrap();
9968 test_git(&repro, &["init"]);
9969 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
9970
9971 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9972 .unwrap();
9973
9974 assert!(repository_has_recoverable_work(&path, true).unwrap());
9975 assert!(repro.join(".git").exists());
9976 }
9977
9978 #[test]
9982 fn a_repository_inside_a_nested_checkout_is_left_alone() {
9983 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
9984 exclude_paths(&repo, &["target/"]);
9985 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9986 let repro = path.join("target/repro");
9987 let vendored = repro.join("vendor/dep");
9988 std::fs::create_dir_all(&vendored).unwrap();
9989 test_git(&repro, &["init"]);
9990 test_git(&vendored, &["init"]);
9991
9992 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9993 .unwrap();
9994
9995 assert!(vendored.join(".git").exists());
9996 }
9997
9998 #[test]
9999 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10000 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10001
10002 let state = ignored_untracked_state(repo.root()).unwrap();
10003
10004 let relative = path.strip_prefix(repo.root()).unwrap();
10005 assert!(
10006 state.files.contains_key(relative),
10007 "{:?}",
10008 state.files.keys().collect::<Vec<_>>()
10009 );
10010 assert!(state.is_ignored(relative));
10011 }
10012
10013 #[test]
10014 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10015 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10016 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10017 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10018 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10019
10020 repo.refuse_new_ignored_files(repo.root(), &baseline)
10021 .unwrap();
10022 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10023 .unwrap();
10024 }
10025
10026 #[test]
10027 fn deleting_a_resident_worktree_during_a_call_is_refused() {
10028 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10029 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10030 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10031 std::fs::remove_dir_all(&path).unwrap();
10032
10033 let read_error = repo
10034 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10035 .unwrap_err();
10036 assert!(
10037 read_error.to_string().contains("review-932"),
10038 "{read_error}"
10039 );
10040 let error = repo
10041 .refuse_new_ignored_files(repo.root(), &baseline)
10042 .unwrap_err();
10043
10044 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10045 assert!(error.to_string().contains("review-932"), "{error}");
10046 }
10047
10048 #[test]
10049 fn a_nested_repository_record_is_read_as_a_plain_path() {
10050 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10051 assert_eq!(Path::new("vendor/checkout"), path);
10052 assert!(nested);
10053
10054 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10055 assert_eq!(Path::new("vendor/notes.txt"), path);
10056 assert!(!nested);
10057
10058 assert!(untracked_record(b"/", "untracked").is_err());
10059 }
10060
10061 #[cfg(unix)]
10062 #[test]
10063 fn a_non_utf8_git_path_is_preserved_without_loss() {
10064 use std::os::unix::ffi::OsStrExt;
10065
10066 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10067
10068 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10069 }
10070
10071 #[test]
10072 fn guarded_merge_pins_the_reviewed_head() {
10073 let args = merge_pr_args("36", Some("abc123"), true);
10074 assert_eq!(
10075 vec![
10076 "pr",
10077 "merge",
10078 "36",
10079 "--squash",
10080 "--delete-branch",
10081 "--match-head-commit",
10082 "abc123"
10083 ],
10084 args
10085 );
10086 }
10087
10088 #[test]
10089 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10090 let pr = PrRef {
10091 number: 7,
10092 url: "https://example.test/pull/7".into(),
10093 title: "part one".into(),
10094 };
10095 let result = reconcile_pr_creation(
10096 "split-34-1",
10097 Err(crate::error::SparError::new("connection lost")),
10098 Ok(Some(pr)),
10099 )
10100 .unwrap();
10101 assert_eq!(7, result.number);
10102 }
10103
10104 #[test]
10105 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10106 let error = reconcile_pr_creation(
10107 "split-34-1",
10108 Err(crate::error::SparError::new("permission denied")),
10109 Ok(None),
10110 )
10111 .unwrap_err();
10112 assert!(error.to_string().contains("permission denied"), "{error}");
10113 }
10114
10115 #[test]
10116 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10117 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10118 assert!(pr_for_base(text, "split-34-2", "split-34-1")
10119 .unwrap()
10120 .is_none());
10121 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10122 assert_eq!(7, found.number);
10123 }
10124
10125 #[test]
10126 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10127 let result = reconcile_comment_post(
10128 34,
10129 "the summary",
10130 crate::error::SparError::new("connection lost"),
10131 Ok(vec![serde_json::json!({"body": "the summary"})]),
10132 );
10133 assert!(result.is_ok(), "{result:?}");
10134 }
10135
10136 #[test]
10137 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10138 let error = reconcile_comment_post(
10139 34,
10140 "the summary",
10141 crate::error::SparError::new("connection lost"),
10142 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10143 )
10144 .unwrap_err();
10145 assert_eq!("connection lost", error.to_string());
10146 }
10147
10148 #[test]
10149 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10150 let error = reconcile_comment_post(
10151 34,
10152 "the summary",
10153 crate::error::SparError::new("connection lost"),
10154 Err(crate::error::SparError::new("comments unavailable")),
10155 )
10156 .unwrap_err();
10157 assert!(
10158 error.to_string().contains("could not be verified"),
10159 "{error}"
10160 );
10161 assert!(
10162 error.to_string().contains("comments unavailable"),
10163 "{error}"
10164 );
10165 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10166 assert!(!error.worth_retrying());
10167 }
10168
10169 #[test]
10170 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10171 let result = reconcile_issue_edit(
10172 34,
10173 "wanted body",
10174 crate::error::SparError::new("connection lost"),
10175 Ok("wanted body".to_string()),
10176 );
10177 assert!(result.is_ok(), "{result:?}");
10178 }
10179
10180 #[test]
10181 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10182 let error = reconcile_issue_edit(
10183 34,
10184 "wanted body",
10185 crate::error::SparError::new("connection lost"),
10186 Err(crate::error::SparError::new("issue unavailable")),
10187 )
10188 .unwrap_err();
10189 assert!(
10190 error.to_string().contains("could not be verified"),
10191 "{error}"
10192 );
10193 assert!(error.to_string().contains("issue unavailable"), "{error}");
10194 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10195 assert!(!error.worth_retrying());
10196 }
10197
10198 #[test]
10199 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10200 let found = ExistingIssue {
10201 number: 101,
10202 url: "https://example.test/issues/101".into(),
10203 title: "child".into(),
10204 body: "body".into(),
10205 open: true,
10206 };
10207 let url = reconcile_issue_creation(
10208 "child",
10209 Err(crate::error::SparError::new("connection lost")),
10210 Ok(Some(found)),
10211 )
10212 .unwrap();
10213 assert_eq!("https://example.test/issues/101", url);
10214 }
10215
10216 #[test]
10217 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10218 let error = reconcile_issue_creation(
10219 "child",
10220 Err(crate::error::SparError::new("permission denied")),
10221 Ok(None),
10222 )
10223 .unwrap_err();
10224 assert!(error.to_string().contains("permission denied"), "{error}");
10225 }
10226
10227 #[test]
10228 fn an_unverifiable_issue_creation_is_marked_uncertain() {
10229 let error = reconcile_issue_creation(
10230 "child",
10231 Err(crate::error::SparError::new("connection lost")),
10232 Err(crate::error::SparError::new("issues unavailable")),
10233 )
10234 .unwrap_err();
10235 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10236 assert!(!error.worth_retrying());
10237 }
10238
10239 #[test]
10240 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10241 let result = reconcile_failed_split_push(
10242 "split-34-1",
10243 crate::error::SparError::new("connection lost"),
10244 Ok("abc123\n".into()),
10245 Ok("abc123\trefs/heads/split-34-1\n".into()),
10246 );
10247 assert!(result.is_ok(), "{result:?}");
10248 }
10249
10250 #[test]
10251 fn a_split_push_collision_is_definite_and_never_overwrites() {
10252 let error = reconcile_failed_split_push(
10253 "split-34-1",
10254 crate::error::SparError::new("lease rejected"),
10255 Ok("abc123\n".into()),
10256 Ok("def456\trefs/heads/split-34-1\n".into()),
10257 )
10258 .unwrap_err();
10259 assert!(!error.retain_worktree());
10260 assert!(
10261 error.to_string().contains("Nothing was overwritten"),
10262 "{error}"
10263 );
10264 }
10265
10266 #[test]
10267 fn an_unreadable_split_push_result_keeps_the_worktree() {
10268 let error = reconcile_failed_split_push(
10269 "split-34-1",
10270 crate::error::SparError::new("connection lost"),
10271 Ok("abc123\n".into()),
10272 Err(crate::error::SparError::new("origin unavailable")),
10273 )
10274 .unwrap_err();
10275 assert!(error.retain_worktree());
10276 assert!(error.to_string().contains("could not confirm"), "{error}");
10277 }
10278
10279 #[test]
10283 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10284 let repo = repo_for_titles();
10285 for raw in [
10286 "Retry loop spins \u{2014} Retry-After parses to zero",
10287 "plain title",
10288 " spread over\nlines ",
10289 "\u{1F916} Generated with something",
10290 &format!("a \u{2014} {}", "very long title ".repeat(20)),
10291 &"x".repeat(300),
10292 &format!("{} \u{2014} end", "y".repeat(88)),
10293 &{
10298 let tail = "a\u{2014}b c\u{2014}d";
10299 let pad = Style::default().max_title_chars - tail.chars().count();
10300 format!("{}{tail}", "w".repeat(pad))
10301 },
10302 ] {
10303 let once = repo.clean_title(raw).unwrap();
10304 let twice = repo.clean_title(&once).unwrap();
10305 assert_eq!(once, twice, "not idempotent for {raw:?}");
10306 assert!(
10307 once.chars().count() <= repo.style.max_title_chars,
10308 "over budget: {once:?}"
10309 );
10310 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10311 }
10312 }
10313
10314 #[test]
10315 fn a_title_with_an_em_dash_survives_as_readable_text() {
10316 let repo = repo_for_titles();
10317 assert_eq!(
10318 "Retry loop spins, Retry-After parses to zero",
10319 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10320 .unwrap()
10321 );
10322 }
10323
10324 #[test]
10325 fn sh_quote_survives_a_quote() {
10326 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10327 }
10328
10329 #[test]
10330 fn sh_quote_wraps_a_space() {
10331 assert_eq!(
10332 "'/Applications/My App/spar'",
10333 sh_quote("/Applications/My App/spar")
10334 );
10335 }
10336
10337 #[test]
10338 fn finished_states_are_recognised_case_insensitively() {
10339 assert!(is_finished("MERGED"));
10340 assert!(is_finished("closed"));
10341 assert!(!is_finished("OPEN"));
10342 assert!(!is_finished(""));
10343 }
10344
10345 fn state() -> PersistedState {
10346 PersistedState {
10347 version: 1,
10348 checkpoint: 0,
10349 round: 4,
10350 next_actor: "codex".into(),
10351 status: Status::Pending,
10352 pr_head: "abc123".into(),
10353 ledger: Ledger::new(),
10354 filed: vec![],
10355 open_findings: vec![Finding {
10356 severity: Severity::Blocking,
10357 title: "Unchecked error".into(),
10358 detail: "the failure is discarded".into(),
10359 file: "src/a.rs:12".into(),
10360 ..Finding::default()
10361 }],
10362 disputes: vec![Dispute {
10363 title: "Retry limit".into(),
10364 file: "src/net.rs".into(),
10365 reasoning: "the caller already bounds it".into(),
10366 }],
10367 noted: vec![Finding {
10368 severity: Severity::NonBlocking,
10369 title: "Timeout is fixed".into(),
10370 file: "src/config.rs".into(),
10371 ..Finding::default()
10372 }],
10373 }
10374 }
10375
10376 #[test]
10377 fn a_state_comment_round_trips() {
10378 let body = format!(
10379 "{STATE_MARKER}\n{}\n-->",
10380 serde_json::to_string(&state()).unwrap()
10381 );
10382 let back = parse_state_comment(&body).unwrap();
10383 assert_eq!(4, back.round);
10384 assert_eq!("codex", back.next_actor);
10385 assert_eq!("abc123", back.pr_head);
10386 assert_eq!("Unchecked error", back.open_findings[0].title);
10387 assert_eq!("src/net.rs", back.disputes[0].file);
10388 assert_eq!("Timeout is fixed", back.noted[0].title);
10389 }
10390
10391 #[test]
10392 fn old_state_without_new_lists_still_parses() {
10393 let body = format!(
10394 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
10395 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10396 );
10397 let back = parse_state_comment(&body).expect("old state");
10398 assert!(back.open_findings.is_empty());
10399 assert!(back.disputes.is_empty());
10400 assert!(back.noted.is_empty());
10401 assert!(back.pr_head.is_empty());
10402 assert_eq!(0, back.checkpoint);
10403 }
10404
10405 #[test]
10406 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
10407 let mut local = state();
10408 local.pr_head = "old".into();
10409 local.round = 9;
10410 let mut remote = state();
10411 remote.pr_head = "current".into();
10412 remote.round = 4;
10413
10414 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10415 assert_eq!("current", chosen.pr_head);
10416 assert_eq!(4, chosen.round);
10417 }
10418
10419 #[test]
10420 fn checkpoint_order_breaks_same_round_ties() {
10421 let mut local = state();
10422 local.pr_head = "current".into();
10423 local.round = 4;
10424 local.checkpoint = 8;
10425 let mut remote = local.clone();
10426 remote.checkpoint = 7;
10427 remote.open_findings.clear();
10428
10429 let chosen = choose_state_for_head(vec![local], "current").unwrap();
10430 assert_eq!(8, chosen.checkpoint);
10431
10432 let mut local = state();
10433 local.pr_head = "current".into();
10434 local.round = 4;
10435 local.checkpoint = 8;
10436 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
10437 assert_eq!(8, chosen.checkpoint);
10438 }
10439
10440 #[test]
10441 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
10442 let mut local = state();
10443 local.pr_head = "current".into();
10444 local.round = 4;
10445 local.open_findings.push(Finding {
10446 title: "local checkpoint".into(),
10447 ..Finding::default()
10448 });
10449 let mut remote = state();
10450 remote.pr_head = "current".into();
10451 remote.round = 4;
10452
10453 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10454 assert_eq!(
10455 "local checkpoint",
10456 chosen.open_findings.last().unwrap().title
10457 );
10458 }
10459
10460 #[test]
10462 fn the_state_block_is_an_html_comment() {
10463 let body = format!(
10464 "{STATE_MARKER}\n{}\n-->",
10465 serde_json::to_string(&state()).unwrap()
10466 );
10467 assert!(body.starts_with("<!--"));
10468 assert!(body.trim_end().ends_with("-->"));
10469 assert!(!body[..body.find('{').unwrap()].contains("-->"));
10470 }
10471
10472 #[test]
10473 fn an_unrelated_json_block_is_not_state() {
10474 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
10475 }
10476
10477 #[test]
10484 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
10485 let block = format!(
10486 "{STATE_MARKER}\n{}\n-->",
10487 serde_json::to_string(&state()).unwrap()
10488 );
10489 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
10490 assert!(
10491 is_state_comment(&block, "Spar-Bot", "spar-bot"),
10492 "a login differs only in case"
10493 );
10494
10495 assert!(
10496 !is_state_comment(
10497 "why is there a `<!-- spar:state` block on this PR?",
10498 "maintainer",
10499 "spar-bot"
10500 ),
10501 "a question about the block was treated as the block"
10502 );
10503 assert!(
10504 !is_state_comment(&block, "maintainer", "spar-bot"),
10505 "somebody else's comment is not spar's to rewrite"
10506 );
10507 assert!(
10508 !is_state_comment(
10509 &format!("here is what spar writes:\n\n{block}"),
10510 "spar-bot",
10511 "spar-bot"
10512 ),
10513 "the marker has to open the body, not appear in it"
10514 );
10515 assert!(
10516 !is_state_comment(
10517 &format!("{STATE_MARKER}\n{{not json\n-->"),
10518 "spar-bot",
10519 "spar-bot"
10520 ),
10521 "a block that does not parse is not state"
10522 );
10523 assert!(
10524 !is_state_comment(&block, "spar-bot", ""),
10525 "an unknown viewer proves nothing about who wrote it"
10526 );
10527 }
10528
10529 #[test]
10530 fn a_malformed_state_comment_is_none_not_a_panic() {
10531 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
10532 }
10533
10534 #[test]
10535 fn atomic_write_leaves_no_temp_file() {
10536 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
10537 let _ = std::fs::remove_dir_all(&dir);
10538 let path = dir.join("state").join("pr-7.json");
10539 write_json_atomic(&path, &state()).unwrap();
10540 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
10541 .unwrap()
10542 .flatten()
10543 .filter_map(|e| e.file_name().to_str().map(str::to_string))
10544 .collect();
10545 assert_eq!(vec!["pr-7.json".to_string()], files);
10546 let _ = std::fs::remove_dir_all(&dir);
10547 }
10548
10549 #[test]
10550 fn atomic_write_overwrites_rather_than_accumulating() {
10551 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
10552 let _ = std::fs::remove_dir_all(&dir);
10553 let path = dir.join("pr-7.json");
10554 for round in 1..4 {
10555 let mut s = state();
10556 s.round = round;
10557 write_json_atomic(&path, &s).unwrap();
10558 }
10559 let back: PersistedState =
10560 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10561 assert_eq!(3, back.round);
10562 let _ = std::fs::remove_dir_all(&dir);
10563 }
10564
10565 #[test]
10566 fn style_from_env_defaults_to_enforcing() {
10567 std::env::remove_var("SPAR_BAN_EM_DASH");
10568 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
10569 let style = style_from_env();
10570 assert!(style.ban_em_dash && style.ban_ai_attribution);
10571 assert!(
10572 !style.terse,
10573 "the commit filter must not truncate a commit message"
10574 );
10575 }
10576
10577 #[test]
10582 fn a_push_denial_is_read_from_either_transport() {
10583 let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
10584 fatal: Could not read from remote repository.\n\n\
10585 Please make sure you have the correct access rights\n\
10586 and the repository exists.";
10587 let https = "remote: Permission to example/project.git denied to someone.\n\
10588 fatal: unable to access 'https://github.com/example/project.git/': The \
10589 requested URL returned error: 403";
10590 let denied = Some("Permission to example/project.git denied to someone.");
10591 assert_eq!(denied, push_denial(ssh));
10592 assert_eq!(denied, push_denial(https));
10593
10594 let stale = "To github.com:example/project.git\n \
10595 ! [rejected] HEAD -> issue-7 (stale info)\n\
10596 error: failed to push some refs to 'github.com:example/project.git'";
10597 assert_eq!(None, push_denial(stale));
10598
10599 let err = push_error(
10600 &PushTarget::Origin,
10601 "issue-7",
10602 &crate::error::SparError::new(ssh),
10603 );
10604 assert_eq!(
10605 "could not push to origin/issue-7. Permission to example/project.git denied to \
10606 someone.",
10607 err.to_string()
10608 );
10609 }
10610
10611 #[cfg(unix)]
10615 fn denied_fixture(
10616 tag: &str,
10617 fork_when_denied: bool,
10618 ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
10619 use std::os::unix::fs::PermissionsExt;
10620 use std::sync::atomic::{AtomicU32, Ordering};
10621 static NEXT: AtomicU32 = AtomicU32::new(0);
10622 let id = NEXT.fetch_add(1, Ordering::Relaxed);
10623 let root =
10624 std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
10625 let origin = root.join("origin.git");
10626 let fork = root.join("fork.git");
10627 let work = root.join("work");
10628 for dir in [&origin, &fork, &work] {
10629 std::fs::create_dir_all(dir).unwrap();
10630 }
10631 test_git(&origin, &["init", "--bare", "-b", "main"]);
10632 test_git(&fork, &["init", "--bare", "-b", "main"]);
10633 test_git(&work, &["init", "-b", "main"]);
10634 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
10635 test_git(&work, &["config", "user.name", "spar test"]);
10636 test_git(&work, &["config", "commit.gpgsign", "false"]);
10637 std::fs::write(work.join("README.md"), "seed\n").unwrap();
10638 test_git(&work, &["add", "."]);
10639 test_git(&work, &["commit", "-m", "seed"]);
10640 test_git(
10641 &work,
10642 &["remote", "add", "origin", origin.to_str().unwrap()],
10643 );
10644 test_git(&work, &["push", "-q", "origin", "main"]);
10645 let hook = origin.join("hooks").join("pre-receive");
10647 std::fs::write(
10648 &hook,
10649 "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
10650 )
10651 .unwrap();
10652 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10653 test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
10654 std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
10655 test_git(&work, &["add", "."]);
10656 test_git(&work, &["commit", "-m", "Fix it"]);
10657
10658 let mut text =
10659 String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
10660 if !fork_when_denied {
10661 text.push_str("[loop]\nfork_when_denied = false\n");
10662 }
10663 let cfg = crate::config::parse(&text).unwrap();
10664 let repo = Repo::open(&work, &cfg).unwrap();
10665 repo.fork
10667 .set(Fork {
10668 owner: "someone".into(),
10669 full_name: "someone/project".into(),
10670 url: fork.to_str().unwrap().into(),
10671 })
10672 .unwrap();
10673 (ReviewFixture { root }, repo, work, origin, fork)
10674 }
10675
10676 #[cfg(unix)]
10677 #[test]
10678 fn a_new_branch_origin_refuses_is_published_from_the_fork() {
10679 let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
10680 let first = test_git(&work, &["rev-parse", "HEAD"]);
10681
10682 let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
10683
10684 assert_eq!("someone:issue-7", target.head("issue-7"));
10685 assert_eq!(
10686 first.trim(),
10687 test_git(&fork, &["rev-parse", "issue-7"]).trim()
10688 );
10689 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
10690 .trim()
10691 .is_empty());
10692 assert_eq!(
10693 WriteSummary {
10694 attempted: 1,
10695 failed: 0
10696 },
10697 repo.write_summary(),
10698 "a refusal the fork answered is not a failed write"
10699 );
10700
10701 std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
10704 test_git(&work, &["commit", "-qam", "Fix it again"]);
10705 repo.push_to(&work, &target, "issue-7", Some(first.trim()))
10706 .unwrap();
10707 let second = test_git(&work, &["rev-parse", "HEAD"]);
10708 assert_eq!(
10709 second.trim(),
10710 test_git(&fork, &["rev-parse", "issue-7"]).trim()
10711 );
10712 }
10713
10714 #[cfg(unix)]
10718 #[test]
10719 fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
10720 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
10721 test_git(
10722 &work,
10723 &[
10724 "push",
10725 "-q",
10726 fork.to_str().unwrap(),
10727 "main:refs/heads/issue-7",
10728 ],
10729 );
10730 let head = test_git(&work, &["rev-parse", "HEAD"]);
10731
10732 repo.publish_new_branch(&work, "issue-7", "main").unwrap();
10733
10734 assert_eq!(
10735 head.trim(),
10736 test_git(&fork, &["rev-parse", "issue-7"]).trim()
10737 );
10738 assert_eq!(0, repo.write_summary().failed);
10739 }
10740
10741 #[cfg(unix)]
10742 #[test]
10743 fn work_only_the_fork_has_is_not_published_over() {
10744 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
10745 test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
10746 std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
10747 test_git(&work, &["add", "."]);
10748 test_git(&work, &["commit", "-qm", "Somebody's work"]);
10749 test_git(
10750 &work,
10751 &[
10752 "push",
10753 "-q",
10754 fork.to_str().unwrap(),
10755 "elsewhere:refs/heads/issue-7",
10756 ],
10757 );
10758 let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
10759 test_git(&work, &["checkout", "-q", "issue-7"]);
10760
10761 let err = repo
10762 .publish_new_branch(&work, "issue-7", "main")
10763 .unwrap_err()
10764 .to_string();
10765
10766 assert!(
10767 err.contains(
10768 "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
10769 ),
10770 "{err}"
10771 );
10772 assert!(err.contains("--delete issue-7"), "{err}");
10773 assert_eq!(
10774 theirs.trim(),
10775 test_git(&fork, &["rev-parse", "issue-7"]).trim()
10776 );
10777 assert_eq!(
10778 WriteSummary {
10779 attempted: 1,
10780 failed: 1
10781 },
10782 repo.write_summary()
10783 );
10784 }
10785
10786 #[test]
10788 fn a_rejected_push_names_the_ref_it_rejected() {
10789 let stale = "To github.com:example/project.git\n \
10790 ! [rejected] HEAD -> issue-7 (stale info)\n\
10791 error: failed to push some refs to 'github.com:example/project.git'";
10792
10793 let err = push_error(
10794 &PushTarget::Origin,
10795 "issue-7",
10796 &crate::error::SparError::new(stale),
10797 )
10798 .to_string();
10799
10800 assert!(
10801 err.starts_with(
10802 "could not push to origin/issue-7. [rejected] HEAD -> issue-7 (stale info)"
10803 ),
10804 "{err}"
10805 );
10806 }
10807
10808 #[test]
10809 fn a_fork_is_reached_by_whatever_reaches_origin() {
10810 let cases = [
10811 (
10812 "git@github.com:example/project.git",
10813 Some("git@github.com:someone/project.git"),
10814 ),
10815 (
10817 "github.com:example/project.git",
10818 Some("github.com:someone/project.git"),
10819 ),
10820 (
10822 "git@github-work:example/project.git",
10823 Some("git@github-work:someone/project.git"),
10824 ),
10825 (
10827 "ssh://git@ssh.github.com:443/example/project.git",
10828 Some("ssh://git@ssh.github.com:443/someone/project.git"),
10829 ),
10830 (
10831 "https://github.com/example/project",
10832 Some("https://github.com/someone/project"),
10833 ),
10834 (
10835 "https://ghe.example.com/scm/example/project.git/",
10836 Some("https://ghe.example.com/scm/someone/project.git"),
10837 ),
10838 (
10839 "git@host:/srv/example/project.git",
10840 Some("git@host:/srv/someone/project.git"),
10841 ),
10842 ("/srv/git/project.git", None),
10843 ("https://github.com/project", None),
10844 ("", None),
10845 ];
10846 for (origin, want) in cases {
10847 assert_eq!(
10848 want.map(str::to_string),
10849 fork_url(origin, "someone/project"),
10850 "{origin}"
10851 );
10852 }
10853 }
10854
10855 #[test]
10856 fn a_fork_found_or_made_is_reached_the_way_origin_is() {
10857 let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
10858 "ssh_url":"git@github.com:someone/project.git",
10859 "clone_url":"https://github.com/someone/project.git","fork":true}"#;
10860 let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
10861 "sshUrl":"git@github.com:someone/project.git",
10862 "url":"https://github.com/someone/project"}]"#;
10863
10864 let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
10865 assert_eq!("git@github-work:someone/project.git", fork.url);
10866 assert_eq!("someone", fork.owner);
10867 assert_eq!("someone/project", fork.full_name);
10868 let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
10869 .unwrap()
10870 .unwrap();
10871 assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
10872 assert_eq!(
10873 None,
10874 fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
10875 );
10876
10877 let fork = fork_from_api(made, "https://github.com/project").unwrap();
10880 assert_eq!("https://github.com/someone/project.git", fork.url);
10881 let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
10882 assert_eq!("git@github.com:someone/project.git", fork.url);
10883
10884 assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
10885 assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
10886 }
10887
10888 #[test]
10889 fn only_the_viewers_own_fork_can_be_pushed_to() {
10890 let pr = |owner: &str, name: &str| PrView {
10891 number: 9,
10892 url: String::new(),
10893 title: String::new(),
10894 body: String::new(),
10895 head_ref_name: "issue-7".into(),
10896 base_ref_name: "main".into(),
10897 state: "OPEN".into(),
10898 closing_issues_references: Vec::new(),
10899 is_cross_repository: true,
10900 head_repository_owner: Some(crate::model::Author {
10901 login: owner.into(),
10902 }),
10903 head_repository: Some(crate::model::HeadRepository { name: name.into() }),
10904 };
10905 let origin = "git@github.com:example/project.git";
10906
10907 assert_eq!(
10908 Some(PushTarget::Fork(Fork {
10909 owner: "Someone".into(),
10910 full_name: "Someone/project".into(),
10911 url: "git@github.com:Someone/project.git".into(),
10912 })),
10913 own_fork_target(&pr("Someone", "project"), "someone", origin)
10914 );
10915 assert_eq!(
10916 None,
10917 own_fork_target(&pr("other", "project"), "someone", origin)
10918 );
10919 assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
10920 assert_eq!(
10921 None,
10922 own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
10923 );
10924 let mut unnamed = pr("someone", "project");
10925 unnamed.head_repository_owner = None;
10926 assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
10927 }
10928
10929 #[cfg(unix)]
10932 #[test]
10933 fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
10934 let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
10935 test_git(
10936 &work,
10937 &[
10938 "push",
10939 "-q",
10940 fork.to_str().unwrap(),
10941 "issue-7:refs/heads/issue-7",
10942 ],
10943 );
10944 let published = test_git(&work, &["rev-parse", "issue-7"]);
10945 test_git(&work, &["checkout", "-q", "main"]);
10946 let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
10947 let pr = PrView {
10948 number: 31,
10949 url: String::new(),
10950 title: String::new(),
10951 body: String::new(),
10952 head_ref_name: "issue-7".into(),
10953 base_ref_name: "main".into(),
10954 state: "OPEN".into(),
10955 closing_issues_references: Vec::new(),
10956 is_cross_repository: true,
10957 head_repository_owner: None,
10958 head_repository: None,
10959 };
10960
10961 let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
10962
10963 assert_eq!("issue-7", branch);
10964 assert_eq!(
10965 published.trim(),
10966 test_git(&path, &["rev-parse", "HEAD"]).trim()
10967 );
10968 std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
10969 test_git(&path, &["commit", "-qam", "Answer a comment"]);
10970 repo.push_to(&path, &target, &branch, Some(published.trim()))
10971 .unwrap();
10972 assert_eq!(
10973 test_git(&path, &["rev-parse", "HEAD"]).trim(),
10974 test_git(&fork, &["rev-parse", "issue-7"]).trim()
10975 );
10976 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
10977 .trim()
10978 .is_empty());
10979 }
10980
10981 #[cfg(unix)]
10982 #[test]
10983 fn with_forking_off_a_refused_push_says_why_and_how() {
10984 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
10985
10986 let err = repo
10987 .publish_new_branch(&work, "issue-7", "main")
10988 .unwrap_err()
10989 .to_string();
10990
10991 assert!(
10992 err.contains("Permission to example/project.git denied to someone."),
10993 "{err}"
10994 );
10995 assert!(err.contains("fork_when_denied = true"), "{err}");
10996 assert!(test_git(&fork, &["branch", "--list", "issue-7"])
10997 .trim()
10998 .is_empty());
10999 assert_eq!(
11000 WriteSummary {
11001 attempted: 1,
11002 failed: 1
11003 },
11004 repo.write_summary()
11005 );
11006 }
11007}
11008
11009#[cfg(test)]
11010mod comment_page_tests {
11011 use super::*;
11012
11013 #[test]
11014 fn a_single_merged_array_is_read() {
11015 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11016 assert_eq!(2, pages.len());
11017 assert_eq!(Some(2), pages[1]["id"].as_i64());
11018 }
11019
11020 #[test]
11021 fn concatenated_pages_from_an_older_gh_are_read_too() {
11022 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11023 assert_eq!(2, pages.len());
11024 }
11025
11026 #[test]
11030 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11031 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11032 let pages = parse_comment_pages(text);
11033 assert_eq!(2, pages.len(), "{pages:?}");
11034 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11035 }
11036
11037 #[test]
11038 fn empty_output_is_no_comments_not_a_panic() {
11039 assert!(parse_comment_pages("").is_empty());
11040 assert!(parse_comment_pages(" ").is_empty());
11041 assert!(parse_comment_pages("[]").is_empty());
11042 }
11043
11044 #[test]
11045 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11046 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11047 }
11048
11049 #[test]
11050 fn a_write_postcheck_rejects_truncated_comment_pages() {
11051 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11052 assert!(
11053 error.to_string().contains("unexpected comment pages"),
11054 "{error}"
11055 );
11056 }
11057
11058 #[test]
11059 fn a_write_postcheck_rejects_empty_or_non_array_output() {
11060 assert!(try_parse_comment_pages("").is_err());
11061 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11062 assert!(try_parse_comment_pages("[]").is_ok());
11063 }
11064
11065 #[test]
11066 fn state_is_found_in_the_last_matching_comment() {
11067 let payload = |round: u32| {
11068 format!(
11069 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11070 )
11071 };
11072 let text = serde_json::to_string(&serde_json::json!([
11073 {"id": 1, "body": payload(1)},
11074 {"id": 2, "body": "looks good to me"},
11075 {"id": 3, "body": payload(5)},
11076 ]))
11077 .unwrap();
11078 let pages = parse_comment_pages(&text);
11079 let last = pages
11080 .iter()
11081 .rev()
11082 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11083 .unwrap();
11084 assert_eq!(5, last.round);
11085 }
11086}
11087
11088#[cfg(test)]
11089mod linked_pr_tests {
11090 use super::*;
11091
11092 const REAL_PAYLOAD: &str = r#"[
11097 {"number":14252,"title":"fix: reject leading-dash branch names",
11098 "url":"https://github.com/cli/cli/pull/14252",
11099 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11100 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11101 "url":"https://github.com/cli/cli/issues/14238"}]},
11102 {"number":14217,"title":"another change",
11103 "url":"https://github.com/cli/cli/pull/14217",
11104 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11105 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11106 "url":"https://github.com/cli/cli/issues/9761"}]},
11107 {"number":14200,"title":"unlinked work",
11108 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11109 ]"#;
11110
11111 #[test]
11112 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11113 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11114 assert_eq!(14252, pr.number);
11115 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11116 }
11117
11118 #[test]
11119 fn the_right_pr_is_picked_out_of_several() {
11120 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11121 }
11122
11123 #[test]
11124 fn an_issue_nobody_is_working_on_finds_nothing() {
11125 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11126 }
11127
11128 #[test]
11129 fn an_unlinked_pr_is_never_matched() {
11130 for issue in [14200, 0, 1] {
11132 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11133 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11134 }
11135 }
11136 }
11137
11138 #[test]
11139 fn empty_or_broken_output_is_none_rather_than_a_panic() {
11140 assert!(find_linked_pr("", 1).is_none());
11141 assert!(find_linked_pr("[]", 1).is_none());
11142 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11143 assert!(find_linked_pr("[{\"number\":", 1).is_none());
11144 }
11145
11146 #[test]
11153 fn an_unreadable_list_is_not_an_empty_list() {
11154 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11155 assert!(linked_pr_rows("[{\"number\":").is_none());
11156 assert!(linked_pr_rows("").is_none());
11157
11158 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11159 assert!(empty.is_empty());
11160 assert!(pick_linked_pr(&empty, 14238).is_none());
11161
11162 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11163 assert_eq!(3, listed.len());
11164 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11165 }
11166
11167 #[test]
11169 fn pr_view_reads_the_cross_repository_flag() {
11170 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11171 "baseRefName":"main","state":"OPEN",
11172 "closingIssuesReferences":[],"isCrossRepository":true}"#;
11173 let pr: PrView = serde_json::from_str(json).unwrap();
11174 assert!(pr.is_cross_repository);
11175 assert!(pr.is_open());
11176
11177 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11178 assert!(
11179 !serde_json::from_str::<PrView>(&same_repo)
11180 .unwrap()
11181 .is_cross_repository
11182 );
11183 }
11184}
11185
11186#[cfg(test)]
11187mod min_number_tests {
11188 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11194 let mut numbers: Vec<i64> = open.to_vec();
11195 numbers.sort_unstable();
11196 if min_number > 0 {
11197 numbers.retain(|n| *n >= min_number);
11198 }
11199 numbers.truncate(limit);
11200 numbers
11201 }
11202
11203 #[test]
11204 fn the_floor_is_applied_before_the_cap_not_after() {
11205 let open = [12, 13, 14, 480, 481, 482];
11206 assert_eq!(vec![480, 481], pick(&open, 2, 480));
11207 assert!(!pick(&open, 2, 480).is_empty());
11210 }
11211
11212 #[test]
11213 fn no_floor_keeps_the_old_behaviour() {
11214 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11215 }
11216
11217 #[test]
11218 fn the_floor_is_inclusive() {
11219 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11220 }
11221
11222 #[test]
11223 fn a_floor_above_everything_open_yields_nothing() {
11224 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11225 }
11226}
11227
11228#[cfg(test)]
11229mod review_record_tests {
11230 use super::*;
11231 use serde_json::json;
11232
11233 #[test]
11234 fn prune_state_knows_which_files_belong_to_a_pull_request() {
11235 assert_eq!(Some(12), state_file_number("pr-12.json"));
11236 assert_eq!(Some(12), state_file_number("review-12.json"));
11237 assert_eq!(Some(12), state_file_number("checkin-12.json"));
11238 for shared in [
11239 "spend.json",
11240 "implementors.json",
11241 "contested.json",
11242 "listen.json",
11243 "pr-.json",
11244 "pr-12.json.tmp",
11245 "pr-x.json",
11246 ] {
11247 assert_eq!(None, state_file_number(shared), "{shared}");
11248 }
11249 }
11250
11251 #[test]
11252 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11253 let reviews = vec![
11254 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11255 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11256 ];
11257 assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11258 assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11259 assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11260 }
11261
11262 #[test]
11266 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11267 let older = vec![
11268 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11269 ];
11270 assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11271 let dismissed = vec![
11272 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11273 ];
11274 assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11275 }
11276
11277 #[test]
11278 fn a_response_splits_into_status_headers_and_body() {
11279 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";
11280 let resp = parse_api_response(text).expect("a response");
11281 assert_eq!(200, resp.status);
11282 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11283 assert_eq!(
11284 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11285 resp.headers.get("last-modified")
11286 );
11287 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11288
11289 let not_modified =
11290 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11291 assert_eq!(304, not_modified.status);
11292 assert!(not_modified.body.is_empty());
11293
11294 assert_eq!(None, parse_api_response(""));
11295 assert_eq!(None, parse_api_response("gh: command not found\n"));
11296 }
11297
11298 #[test]
11299 fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11300 let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11301 {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11302 {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11303 {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11304 {}
11305 ]}}}}})
11306 .to_string();
11307 let made = parse_review_requests(&text).expect("requests");
11308 assert_eq!(2, made.len());
11309 assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11310 assert_eq!("viewer", made[0].who.login);
11311 assert!(made[0].who.slug.is_empty());
11312 assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11313 assert!(made[1].who.login.is_empty());
11314 assert_eq!("example/ours", made[1].who.slug);
11315
11316 let none =
11317 json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11318 assert_eq!(
11319 Some(0),
11320 parse_review_requests(&none.to_string()).map(|m| m.len())
11321 );
11322 assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11323 assert!(parse_review_requests("gh: not json").is_none());
11324 }
11325
11326 #[test]
11327 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11328 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11329 let error = crate::error::SparError::call_failed("gh: boom");
11330 assert!(
11331 reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11332 );
11333
11334 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11335 let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11336 .unwrap_err();
11337 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11338
11339 let unreadable = Err(crate::error::SparError::new("no network"));
11340 let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11341 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11342 assert!(err.to_string().contains("could not be verified"), "{err}");
11343 }
11344}