1use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{CheckCommand, Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20 Contested, ContestedItem, Followup, InlineComment, Issue, IssueRef, ItemKind, LiveHead,
21 PersistedState, PrRef, PrRow, PrSweepRow, PrView, PR_SWEEP_FIELDS,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, log, logdim, logwarn, spar_err};
27
28const ISSUE_FIELDS: &str = "number,title,body,labels,state,stateReason,url,author,comments";
36
37pub const FETCH_CEILING: usize = 500;
41
42pub const STATE_MARKER: &str = "<!-- spar:state";
45
46pub const COMMENT_MARKER: &str = "<!-- spar:comment -->";
54
55pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
63
64const WORKTREE_DIR: &str = ".spar-worktrees";
65const STATE_DIR: &str = ".spar";
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
136const SETUP_TIMEOUT_SECS: u64 = 1800;
139
140#[derive(Debug, Clone)]
141pub struct SplitPushError {
142 message: String,
143 retain_worktree: bool,
144}
145
146impl SplitPushError {
147 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
148 Self {
149 message: message.into(),
150 retain_worktree,
151 }
152 }
153
154 pub fn retain_worktree(&self) -> bool {
155 self.retain_worktree
156 }
157}
158
159impl std::fmt::Display for SplitPushError {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.write_str(&self.message)
162 }
163}
164
165impl std::error::Error for SplitPushError {}
166
167fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
172 match attempt {
173 1 => format!("split-{parent}-{index}"),
174 n => format!("split-{parent}-{index}-{n}"),
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum PushTarget {
181 Origin,
182 Fork(Fork),
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct Fork {
190 pub owner: String,
193 pub full_name: String,
195 pub url: String,
198}
199
200impl PushTarget {
201 pub fn head(&self, branch: &str) -> String {
203 match self {
204 PushTarget::Origin => branch.to_string(),
205 PushTarget::Fork(fork) => format!("{}:{branch}", fork.owner),
206 }
207 }
208
209 fn remote(&self) -> &str {
210 match self {
211 PushTarget::Origin => "origin",
212 PushTarget::Fork(fork) => &fork.url,
213 }
214 }
215
216 fn describe(&self, branch: &str) -> String {
217 match self {
218 PushTarget::Origin => format!("origin/{branch}"),
219 PushTarget::Fork(fork) => format!("{branch} on the fork {}", fork.full_name),
220 }
221 }
222}
223
224#[derive(Debug)]
225pub struct Repo {
226 root: PathBuf,
227 pub style: Style,
228 pub branch_prefix: String,
229 pub state_store: StateStore,
230 pub followups: Followups,
231 pub drafts: Drafts,
232 pub fork_when_denied: bool,
234 setup: CheckCommand,
236 fork: OnceLock<Fork>,
239 viewer: OnceLock<String>,
245 checkpoints: Mutex<BTreeMap<i64, u64>>,
250 writes: WriteStats,
251}
252
253#[derive(Debug, Default)]
254struct WriteStats {
255 attempted: AtomicUsize,
256 failed: AtomicUsize,
257}
258
259#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
260pub(crate) struct WriteSummary {
261 pub(crate) attempted: usize,
262 pub(crate) failed: usize,
263}
264
265impl WriteSummary {
266 pub(crate) fn succeeded(self) -> usize {
267 self.attempted.saturating_sub(self.failed)
268 }
269}
270
271#[derive(Debug, Clone)]
277pub(crate) struct WorktreeBaseline {
278 attributes: AttributeState,
279 ignored_untracked: IgnoredState,
280 git_state: GitState,
281}
282
283#[derive(Debug, Clone)]
289pub(crate) struct WorktreeCheckpoint {
290 path: PathBuf,
291 attributes: AttributeState,
292 git_state: GitState,
293 ignored_untracked: IgnoredState,
294}
295
296#[derive(Debug, Clone, Default, PartialEq, Eq)]
297pub(crate) struct AttributeState {
298 files: BTreeMap<PathBuf, [u8; 32]>,
299}
300
301#[derive(Debug, Clone, Default, PartialEq, Eq)]
306pub(crate) struct IgnoredState {
307 files: BTreeMap<PathBuf, UntrackedFile>,
308 ignored: BTreeSet<PathBuf>,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq)]
318struct UntrackedFile {
319 kind: u8,
320 len: u64,
321 modified: Option<std::time::SystemTime>,
322 created: Option<std::time::SystemTime>,
323 readonly: bool,
324 symlink_target: Option<Vec<u8>>,
325 #[cfg(unix)]
326 device: u64,
327 #[cfg(unix)]
328 inode: u64,
329 #[cfg(unix)]
330 mode: u32,
331 #[cfg(unix)]
332 change_seconds: i64,
333 #[cfg(unix)]
334 change_nanoseconds: i64,
335}
336
337#[derive(Debug, Clone, Default, PartialEq, Eq)]
338pub(crate) struct GitState {
339 repositories: BTreeMap<PathBuf, RepositoryState>,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq)]
343struct RepositoryState {
344 head: String,
345 unsafe_index_flags: Vec<u8>,
346 tracked: BTreeMap<PathBuf, TrackedEntry>,
347 gitlinks: BTreeMap<PathBuf, String>,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
351struct TrackedEntry {
352 index_mode: String,
353 index_oid: String,
354 worktree: Option<WorktreeFile>,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
358struct WorktreeFile {
359 mode: String,
360 #[cfg(unix)]
361 permissions: u32,
362 raw_oid: String,
363 fingerprint: [u8; 32],
364 content: [u8; 32],
365}
366
367struct Gitlink {
368 path: PathBuf,
369 oid: String,
370}
371
372struct IndexEntry {
373 path: PathBuf,
374 mode: String,
375 oid: String,
376}
377
378impl IgnoredState {
379 fn is_ignored(&self, path: &Path) -> bool {
380 self.ignored.contains(path)
381 }
382
383 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
384 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
385 paths.extend(after.files.keys().cloned());
386 paths
387 .into_iter()
388 .filter(|path| {
389 self.files.get(path) != after.files.get(path)
390 || self.is_ignored(path) != after.is_ignored(path)
391 })
392 .collect()
393 }
394
395 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
396 self.files
397 .iter()
398 .filter(|(path, state)| after.files.get(*path) != Some(*state))
399 .map(|(path, _)| path.clone())
400 .collect()
401 }
402
403 fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
404 after
405 .files
406 .keys()
407 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
408 .cloned()
409 .collect()
410 }
411
412 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
422 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
423 paths.extend(after.files.keys());
424 paths.into_iter().any(|path| {
425 if self.files.get(path) == after.files.get(path)
426 && self.is_ignored(path) == after.is_ignored(path)
427 {
428 return false;
429 }
430 !self.generated_move(after, path)
431 })
432 }
433
434 pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
462 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
463 paths.extend(after.files.keys());
464 let mut leavings = ReadOnlyLeavings::default();
465 for path in paths {
466 if self.files.get(path) == after.files.get(path)
467 && self.is_ignored(path) == after.is_ignored(path)
468 {
469 continue;
470 }
471 if !self.ignored_file_move(after, path) {
472 leavings.condemning.push(path.clone());
473 } else if !is_generated_artifact(path) {
474 leavings.tolerated.push(path.clone());
475 }
476 }
477 leavings
478 }
479
480 fn generated_move(&self, after: &Self, path: &Path) -> bool {
494 is_generated_artifact(path) && self.disposable_move(after, path)
495 }
496
497 fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
501 if path.starts_with(WORKTREE_DIR)
502 && self.files.get(path).is_some_and(|file| file.kind == 3)
503 && self.files.get(path) != after.files.get(path)
504 {
505 return false;
506 }
507 self.disposable_move(after, path)
508 }
509
510 fn disposable_move(&self, after: &Self, path: &Path) -> bool {
513 self.disposable(path) && after.disposable(path)
514 }
515
516 fn disposable(&self, path: &Path) -> bool {
519 !self.files.contains_key(path) || self.is_ignored(path)
520 }
521}
522
523impl GitState {
524 pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
530 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
531 prefixes.extend(after.repositories.keys());
532 let mut notes: Vec<String> = Vec::new();
533 let mut paths: Vec<PathBuf> = Vec::new();
534 for prefix in prefixes {
535 let label = describe_repository(prefix);
536 match (
537 self.repositories.get(prefix),
538 after.repositories.get(prefix),
539 ) {
540 (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
541 (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
542 (Some(before), Some(current)) => {
543 if before.head != current.head {
544 notes.push(format!("HEAD moved in {label}"));
545 }
546 if before.unsafe_index_flags != current.unsafe_index_flags {
547 notes.push(format!("index flags changed in {label}"));
548 }
549 paths.extend(
550 differing_keys(&before.gitlinks, ¤t.gitlinks)
551 .chain(differing_keys(&before.tracked, ¤t.tracked))
552 .map(|path| prefix.join(path)),
553 );
554 }
555 (None, None) => {}
556 }
557 }
558 if !paths.is_empty() {
559 paths.sort();
560 paths.dedup();
561 notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
562 }
563 (!notes.is_empty()).then(|| notes.join("; "))
564 }
565
566 pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
569 let difference = self.describe_difference(after)?;
570 let Some(moved) = self.moved_wholesale(after) else {
571 return Some(difference);
572 };
573 if said_once("moved", cwd, &moved) {
576 logwarn!(
577 "the checkout at {} moved while a read-only call was reading it: {moved}. The \
578 answer was kept, and it describes the tree as it stood before the move.",
579 cwd.display()
580 );
581 }
582 None
583 }
584
585 fn moved_wholesale(&self, after: &Self) -> Option<String> {
602 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
603 prefixes.extend(after.repositories.keys());
604 let mut notes: Vec<String> = Vec::new();
605 for prefix in prefixes {
606 let before = self.repositories.get(prefix)?;
608 let current = after.repositories.get(prefix)?;
609 if before.unsafe_index_flags != current.unsafe_index_flags
610 || before.gitlinks != current.gitlinks
611 {
612 return None;
613 }
614 let moved: Vec<&PathBuf> = differing_keys(&before.tracked, ¤t.tracked).collect();
615 if before.head == current.head {
616 if moved.is_empty() {
617 continue;
618 }
619 return None;
620 }
621 if !moved.iter().all(|path| {
622 checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
623 }) {
624 return None;
625 }
626 let label = describe_repository(prefix);
627 notes.push(match moved.len() {
628 0 => format!("HEAD moved in {label}"),
629 count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
630 });
631 }
632 (!notes.is_empty()).then(|| notes.join("; "))
633 }
634}
635
636fn checked_out(entry: Option<&TrackedEntry>) -> bool {
643 entry.is_none_or(|entry| {
644 entry
645 .worktree
646 .as_ref()
647 .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
648 })
649}
650
651pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
662 if paths.is_empty() {
663 return;
664 }
665 let named = list_paths(paths);
666 if !said_once("ignored", cwd, &named) {
667 return;
668 }
669 logdim!(
670 "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
671 project ignores these paths.",
672 paths.len(),
673 cwd.display()
674 );
675}
676
677fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
684 static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
685 SAID.lock()
686 .unwrap_or_else(std::sync::PoisonError::into_inner)
687 .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
688}
689
690fn describe_repository(prefix: &Path) -> String {
693 if prefix.as_os_str().is_empty() {
694 "the worktree".to_string()
695 } else {
696 format!("the submodule at {:?}", prefix.as_os_str())
697 }
698}
699
700fn differing_keys<'a, V: PartialEq>(
702 before: &'a BTreeMap<PathBuf, V>,
703 after: &'a BTreeMap<PathBuf, V>,
704) -> impl Iterator<Item = &'a PathBuf> {
705 let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
706 keys.extend(after.keys());
707 keys.into_iter()
708 .filter(move |key| before.get(*key) != after.get(*key))
709}
710
711#[derive(Default)]
717pub(crate) struct ReadOnlyLeavings {
718 pub(crate) tolerated: Vec<PathBuf>,
722 pub(crate) condemning: Vec<PathBuf>,
724}
725
726pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
730 match (first, second) {
731 (None, None) => None,
732 (Some(one), None) | (None, Some(one)) => Some(one),
733 (Some(first), Some(second)) => Some(format!("{first}; {second}")),
734 }
735}
736
737pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
742 const SHOWN: usize = 5;
743 let mut listed = paths
744 .iter()
745 .take(SHOWN)
746 .map(|path| format!("{:?}", path.as_os_str()))
747 .collect::<Vec<_>>()
748 .join(", ");
749 if paths.len() > SHOWN {
750 listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
751 }
752 listed
753}
754
755#[derive(Default)]
762struct IgnoredArtifacts {
763 new_paths: BTreeSet<PathBuf>,
764 changed_paths: BTreeSet<PathBuf>,
765 removed_paths: BTreeSet<PathBuf>,
766}
767
768impl IgnoredArtifacts {
769 fn left(&mut self, allowed: AllowedArtifacts) {
770 self.new_paths.extend(allowed.present);
771 self.removed_paths.extend(allowed.removed);
772 }
773
774 fn changed(&mut self, allowed: AllowedArtifacts) {
775 self.changed_paths.extend(allowed.present);
776 self.removed_paths.extend(allowed.removed);
777 }
778
779 fn report(&self, cwd: &Path) {
781 let paths: BTreeSet<_> = self
782 .new_paths
783 .iter()
784 .chain(&self.changed_paths)
785 .chain(&self.removed_paths)
786 .cloned()
787 .collect();
788 if !paths.is_empty() {
789 logdim!(
790 "the editing call changed {} ignored file(s) in {}: {}. They are outside \
791 the commit; cleanup checks them separately.",
792 paths.len(),
793 cwd.display(),
794 list_paths(&paths.into_iter().collect::<Vec<_>>())
795 );
796 }
797 }
798}
799
800#[derive(Default)]
804struct AllowedArtifacts {
805 present: Vec<PathBuf>,
806 removed: Vec<PathBuf>,
807}
808
809impl AllowedArtifacts {
810 fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
811 let (present, removed) = paths
812 .into_iter()
813 .partition(|path| after.files.contains_key(path));
814 Self { present, removed }
815 }
816}
817
818fn is_generated_artifact(path: &Path) -> bool {
824 const DIRECTORIES: &[&str] = &[
825 "target",
826 "dist",
827 "node_modules",
828 "__pycache__",
829 ".pytest_cache",
830 ".mypy_cache",
831 ".ruff_cache",
832 ".tox",
833 ".nox",
834 ".venv",
835 "venv",
836 ".gradle",
837 ".build",
838 "DerivedData",
839 ".next",
840 ".nuxt",
841 ".svelte-kit",
842 ".turbo",
843 "coverage",
844 ];
845 path.components().any(|component| {
846 let std::path::Component::Normal(name) = component else {
847 return false;
848 };
849 DIRECTORIES
850 .iter()
851 .any(|directory| name == OsStr::new(directory))
852 })
853}
854
855fn is_desktop_metadata(path: &Path) -> bool {
861 path.file_name() == Some(OsStr::new(".DS_Store"))
862}
863
864fn merge_pr_args<'a>(
865 number: &'a str,
866 expected_head: Option<&'a str>,
867 delete_branch: bool,
868) -> Vec<&'a str> {
869 let mut args = vec!["pr", "merge", number, "--squash"];
870 if delete_branch {
871 args.push("--delete-branch");
872 }
873 if let Some(expected_head) = expected_head {
874 args.extend(["--match-head-commit", expected_head]);
875 }
876 args
877}
878
879fn push_denial(message: &str) -> Option<&str> {
889 message
890 .lines()
891 .map(|line| {
892 let line = line.trim();
893 let line = line.strip_prefix("ERROR:").unwrap_or(line);
894 line.strip_prefix("remote:").unwrap_or(line).trim()
895 })
896 .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
897}
898
899fn push_rejection(message: &str) -> Option<&str> {
902 message
903 .lines()
904 .map(|line| line.trim().trim_start_matches('!').trim())
905 .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
906}
907
908fn push_error(
909 target: &PushTarget,
910 branch: &str,
911 e: &crate::error::SparError,
912) -> crate::error::SparError {
913 let message = e.to_string();
914 if let Some(denied) = push_denial(&message) {
915 return spar_err!("could not push to {}. {denied}", target.describe(branch));
916 }
917 spar_err!(
918 "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
919 target.describe(branch),
920 push_rejection(&message).unwrap_or(e.last_line())
921 )
922}
923
924fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
933 let origin = origin_url.trim();
934 let path_start = match origin.find("://") {
935 Some(scheme) => {
936 let host = scheme + 3;
937 host + origin[host..].find('/')? + 1
938 }
939 None => {
940 let colon = origin.find(':')?;
943 if origin[..colon].contains('/') {
944 return None;
945 }
946 colon + 1
947 }
948 };
949 let path = origin[path_start..].trim_end_matches('/');
950 let (path, suffix) = match path.strip_suffix(".git") {
951 Some(bare) => (bare, ".git"),
952 None => (path, ""),
953 };
954 let segments: Vec<&str> = path.split('/').collect();
955 if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
956 return None;
957 }
958 let prefix = &segments[..segments.len() - 2];
959 let prefix = if prefix.is_empty() {
960 String::new()
961 } else {
962 format!("{}/", prefix.join("/"))
963 };
964 Some(format!(
965 "{}{prefix}{full_name}{suffix}",
966 &origin[..path_start]
967 ))
968}
969
970fn fork_reached_like_origin(
975 owner: String,
976 full_name: String,
977 origin_url: &str,
978 ssh_url: String,
979 https_url: String,
980) -> Fork {
981 let origin = origin_url.trim();
982 let url = fork_url(origin, &full_name).unwrap_or_else(|| {
983 if origin.starts_with("git@") || origin.starts_with("ssh://") {
984 ssh_url
985 } else {
986 https_url
987 }
988 });
989 Fork {
990 owner,
991 full_name,
992 url,
993 }
994}
995
996#[derive(Deserialize)]
997struct ForkOwner {
998 login: String,
999}
1000
1001fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
1003 #[derive(Deserialize)]
1004 struct Answer {
1005 full_name: String,
1006 owner: ForkOwner,
1007 ssh_url: String,
1008 clone_url: String,
1009 }
1010
1011 let answer: Answer = serde_json::from_str(answer.trim())
1012 .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1013 Ok(fork_reached_like_origin(
1014 answer.owner.login,
1015 answer.full_name,
1016 origin_url,
1017 answer.ssh_url,
1018 answer.clone_url,
1019 ))
1020}
1021
1022fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1025 #[derive(Deserialize)]
1026 #[serde(rename_all = "camelCase")]
1027 struct Node {
1028 name_with_owner: String,
1029 owner: ForkOwner,
1030 ssh_url: String,
1031 url: String,
1032 }
1033
1034 let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1035 .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1036 Ok(nodes.into_iter().next().map(|node| {
1037 fork_reached_like_origin(
1038 node.owner.login,
1039 node.name_with_owner,
1040 origin_url,
1041 node.ssh_url,
1042 node.url,
1043 )
1044 }))
1045}
1046
1047fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1050 let owner = pr.head_repository_owner.as_ref()?.login.trim();
1051 let name = pr.head_repository.as_ref()?.name.trim();
1052 if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1053 return None;
1054 }
1055 let full_name = format!("{owner}/{name}");
1056 Some(PushTarget::Fork(Fork {
1057 owner: owner.to_string(),
1058 url: fork_url(origin_url, &full_name)?,
1059 full_name,
1060 }))
1061}
1062
1063fn reconcile_pr_creation(
1064 branch: &str,
1065 created: Result<String>,
1066 found: Result<Option<PrRef>>,
1067) -> Result<PrRef> {
1068 match (created, found) {
1069 (_, Ok(Some(pr))) => Ok(pr),
1070 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1071 "PR creation reported success but none was found for {branch}"
1072 ))),
1073 (Err(create), Ok(None)) => Err(spar_err!(
1074 "could not open a PR for {branch}. {}",
1075 create.last_line()
1076 )),
1077 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1078 "PR creation reported success for {branch}, but it could not be verified. {}",
1079 check.last_line()
1080 ))),
1081 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1082 "could not open a PR for {branch}. {} The result could not be verified: {}",
1083 create.last_line(),
1084 check.last_line()
1085 ))),
1086 }
1087}
1088
1089fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1090 #[derive(Deserialize)]
1091 #[serde(rename_all = "camelCase")]
1092 struct Row {
1093 number: i64,
1094 #[serde(default)]
1095 url: String,
1096 #[serde(default)]
1097 title: String,
1098 base_ref_name: String,
1099 }
1100
1101 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1102 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1103 })?;
1104 Ok(rows
1105 .into_iter()
1106 .find(|row| row.base_ref_name == base)
1107 .map(|row| PrRef {
1108 number: row.number,
1109 url: row.url,
1110 title: row.title,
1111 }))
1112}
1113
1114fn signed(body: &str) -> String {
1117 if body.contains(COMMENT_MARKER) {
1118 return body.to_string();
1119 }
1120 format!("{body}\n\n{COMMENT_MARKER}")
1121}
1122
1123pub fn is_spar_comment(body: &str) -> bool {
1125 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1126}
1127
1128#[derive(Debug, Clone, Default, PartialEq, Eq)]
1130pub struct ApiResponse {
1131 pub status: u16,
1132 pub headers: BTreeMap<String, String>,
1134 pub body: String,
1135}
1136
1137pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1141 let mut lines = stdout.split_inclusive('\n');
1142 let first = lines.next()?.trim_end_matches(['\r', '\n']);
1143 if !first.starts_with("HTTP/") {
1144 return None;
1145 }
1146 let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1147 let mut headers = BTreeMap::new();
1148 let mut body = String::new();
1149 let mut in_body = false;
1150 for line in lines {
1151 if in_body {
1152 body.push_str(line);
1153 continue;
1154 }
1155 let bare = line.trim_end_matches(['\r', '\n']);
1156 if bare.is_empty() {
1157 in_body = true;
1158 continue;
1159 }
1160 if let Some((name, value)) = bare.split_once(':') {
1161 headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1162 }
1163 }
1164 Some(ApiResponse {
1165 status,
1166 headers,
1167 body,
1168 })
1169}
1170
1171const REQUESTS_QUERY: &str = "\
1174query($owner: String!, $repo: String!, $number: Int!) {
1175 repository(owner: $owner, name: $repo) {
1176 pullRequest(number: $number) {
1177 timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1178 nodes {
1179 ... on ReviewRequestedEvent {
1180 createdAt
1181 requestedReviewer {
1182 ... on User { login }
1183 ... on Team { combinedSlug }
1184 }
1185 }
1186 }
1187 }
1188 }
1189 }
1190}";
1191
1192pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1196 let v: Value = serde_json::from_str(text).ok()?;
1197 let nodes = v
1198 .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1199 .as_array()?;
1200 let text_at = |node: &Value, path: &str| {
1201 node.pointer(path)
1202 .and_then(Value::as_str)
1203 .unwrap_or_default()
1204 .to_string()
1205 };
1206 Some(
1207 nodes
1208 .iter()
1209 .map(|node| crate::model::ReviewRequested {
1210 at: text_at(node, "/createdAt"),
1211 who: crate::model::Login {
1212 login: text_at(node, "/requestedReviewer/login"),
1213 slug: text_at(node, "/requestedReviewer/combinedSlug"),
1214 },
1215 })
1216 .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1217 .collect(),
1218 )
1219}
1220
1221pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1225 let stem = name.strip_suffix(".json")?;
1226 ["pr-", "review-", "checkin-"]
1227 .iter()
1228 .find_map(|prefix| stem.strip_prefix(prefix))
1229 .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1230 .and_then(|digits| digits.parse().ok())
1231}
1232
1233fn has_viewer_review(reviews: &[Value], viewer: &str, body: &str, commit: &str) -> bool {
1238 reviews.iter().any(|review| {
1239 let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1240 let standing = !matches!(
1241 field("state").to_uppercase().as_str(),
1242 "DISMISSED" | "PENDING"
1243 );
1244 review
1245 .get("user")
1246 .and_then(|u| u.get("login"))
1247 .and_then(Value::as_str)
1248 .is_some_and(|login| crate::comments::same_login(login, viewer))
1249 && field("body") == body
1250 && field("commit_id").eq_ignore_ascii_case(commit)
1251 && standing
1252 })
1253}
1254
1255fn reconcile_review_post(
1256 number: i64,
1257 body: &str,
1258 viewer: &str,
1259 commit: &str,
1260 post_error: crate::error::SparError,
1261 reviews: Result<Vec<Value>>,
1262) -> Result<()> {
1263 match reviews {
1264 Ok(reviews) if has_viewer_review(&reviews, viewer, body, commit) => Ok(()),
1265 Ok(_) => Err(post_error),
1266 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1267 "could not submit a review on #{number}. {} The result could not be verified: {}",
1268 post_error.last_line(),
1269 read_error.last_line()
1270 ))),
1271 }
1272}
1273
1274fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1279 let comments: Vec<Value> = comments
1280 .iter()
1281 .map(|c| {
1282 let mut row = serde_json::json!({
1283 "path": c.path,
1284 "line": c.line,
1285 "side": "RIGHT",
1286 "body": c.body,
1287 });
1288 if let Some(start) = c.start_line {
1289 row["start_line"] = serde_json::json!(start);
1290 row["start_side"] = serde_json::json!("RIGHT");
1291 }
1292 row
1293 })
1294 .collect();
1295 serde_json::json!({
1296 "commit_id": commit,
1297 "event": event,
1298 "body": body,
1299 "comments": comments,
1300 })
1301}
1302
1303fn refused(e: &crate::error::SparError) -> bool {
1307 e.to_string().contains("(HTTP 422)")
1308}
1309
1310pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1314
1315fn new_side_hunks(diff: &str) -> Hunks {
1322 let mut out = Hunks::new();
1323 let mut path: Option<String> = None;
1324 let (mut old_left, mut new_left) = (0u32, 0u32);
1325 for line in diff.lines() {
1326 if old_left > 0 || new_left > 0 {
1327 match line.as_bytes().first() {
1328 Some(b'+') => new_left = new_left.saturating_sub(1),
1329 Some(b'-') => old_left = old_left.saturating_sub(1),
1330 Some(b'\\') => {}
1331 _ => {
1332 old_left = old_left.saturating_sub(1);
1333 new_left = new_left.saturating_sub(1);
1334 }
1335 }
1336 continue;
1337 }
1338 if line.starts_with("diff --git ") {
1339 path = None;
1340 } else if let Some(rest) = line.strip_prefix("+++ ") {
1341 let rest = if rest.contains(' ') {
1344 rest.strip_suffix('\t').unwrap_or(rest)
1345 } else {
1346 rest
1347 };
1348 path = rest
1349 .strip_prefix("b/")
1350 .filter(|p| !p.is_empty())
1351 .map(str::to_string);
1352 } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1353 old_left = old_len;
1354 new_left = len;
1355 if let (Some(path), true) = (&path, len > 0 && start > 0) {
1356 out.entry(path.clone())
1357 .or_default()
1358 .push((start, start + len - 1));
1359 }
1360 }
1361 }
1362 out
1363}
1364
1365fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1367 let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1368 let (old, new) = ranges.split_once(" +")?;
1369 let range = |s: &str| -> Option<(u32, u32)> {
1370 match s.split_once(',') {
1371 Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1372 None => Some((s.parse().ok()?, 1)),
1373 }
1374 };
1375 Some((range(old)?, range(new)?))
1376}
1377
1378fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1379 comments.iter().any(|comment| {
1380 comment
1381 .get("body")
1382 .and_then(Value::as_str)
1383 .is_some_and(|seen| seen == body)
1384 })
1385}
1386
1387fn reconcile_comment_post(
1388 number: i64,
1389 body: &str,
1390 post_error: crate::error::SparError,
1391 comments: Result<Vec<Value>>,
1392) -> Result<()> {
1393 match comments {
1394 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1395 Ok(_) => Err(post_error),
1396 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1397 "could not comment on #{number}. {} The result could not be verified: {}",
1398 post_error.last_line(),
1399 read_error.last_line()
1400 ))),
1401 }
1402}
1403
1404fn reconcile_issue_edit(
1405 number: i64,
1406 wanted: &str,
1407 edit_error: crate::error::SparError,
1408 observed: Result<String>,
1409) -> Result<()> {
1410 match observed {
1411 Ok(body) if body == wanted => Ok(()),
1412 Ok(_) => Err(spar_err!(
1413 "could not rewrite the body of #{number}. {}",
1414 edit_error.last_line()
1415 )),
1416 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1417 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1418 edit_error.last_line(),
1419 read_error.last_line()
1420 ))),
1421 }
1422}
1423
1424fn issue_url_has_number(url: &str) -> bool {
1425 url.trim()
1426 .rsplit('/')
1427 .next()
1428 .and_then(|tail| tail.parse::<i64>().ok())
1429 .is_some_and(|number| number > 0)
1430}
1431
1432fn reconcile_issue_creation(
1433 title: &str,
1434 created: Result<String>,
1435 found: Result<Option<ExistingIssue>>,
1436) -> Result<String> {
1437 match (created, found) {
1438 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1439 (_, Ok(Some(issue))) => Ok(issue.url),
1440 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1441 "issue creation reported success but no matching issue was found for {title:?}"
1442 ))),
1443 (Err(create), Ok(None)) => Err(spar_err!(
1444 "could not file issue {title:?}. {}",
1445 create.last_line()
1446 )),
1447 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1448 "issue creation reported success for {title:?}, but it could not be verified. {}",
1449 check.last_line()
1450 ))),
1451 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1452 "could not file issue {title:?}. {} The result could not be verified: {}",
1453 create.last_line(),
1454 check.last_line()
1455 ))),
1456 }
1457}
1458
1459fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1460 if output.trim().is_empty() {
1461 return Ok(None);
1462 }
1463 for line in output.lines() {
1464 let mut fields = line.split_whitespace();
1465 let oid = fields.next().unwrap_or_default();
1466 let name = fields.next().unwrap_or_default();
1467 if name == remote_ref && !oid.is_empty() {
1468 return Ok(Some(oid.to_string()));
1469 }
1470 }
1471 Err(spar_err!(
1472 "origin returned an unexpected ref listing for {remote_ref}"
1473 ))
1474}
1475
1476fn reconcile_failed_split_push(
1477 branch: &str,
1478 push_error: crate::error::SparError,
1479 local: Result<String>,
1480 remote: Result<String>,
1481) -> std::result::Result<(), SplitPushError> {
1482 let remote_ref = format!("refs/heads/{branch}");
1483 match (local, remote) {
1484 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1485 Ok(Some(oid)) if oid == local.trim() => Ok(()),
1486 Ok(_) => Err(SplitPushError::new(
1487 format!(
1488 "could not create origin/{branch}. {} The remote branch is absent or points \
1489 somewhere else. Nothing was overwritten.",
1490 push_error.last_line()
1491 ),
1492 false,
1493 )),
1494 Err(check) => Err(SplitPushError::new(
1495 format!(
1496 "could not confirm whether origin/{branch} was created. {} The remote result \
1497 could not be verified: {}",
1498 push_error.last_line(),
1499 check.last_line()
1500 ),
1501 true,
1502 )),
1503 },
1504 (local, remote) => {
1505 let check = match (local, remote) {
1506 (Err(local), Err(remote)) => format!(
1507 "the local commit could not be read: {}; origin could not be read: {}",
1508 local.last_line(),
1509 remote.last_line()
1510 ),
1511 (Err(local), _) => {
1512 format!("the local commit could not be read: {}", local.last_line())
1513 }
1514 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1515 _ => unreachable!(),
1516 };
1517 Err(SplitPushError::new(
1518 format!(
1519 "could not confirm whether origin/{branch} was created. {} The result could \
1520 not be verified because {check}",
1521 push_error.last_line()
1522 ),
1523 true,
1524 ))
1525 }
1526 }
1527}
1528
1529impl Repo {
1530 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1531 let root =
1532 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1533 let inside = proc::run_str(
1536 &["git", "rev-parse", "--is-inside-work-tree"],
1537 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1538 )
1539 .unwrap_or_default();
1540 if inside.trim() != "true" {
1541 bail!("not a git repository: {}", root.display());
1542 }
1543 let repo = Self {
1544 root,
1545 style: cfg.style.clone(),
1546 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1547 state_store: cfg.loop_cfg.state_store,
1548 followups: cfg.loop_cfg.followups,
1549 drafts: cfg.loop_cfg.drafts,
1550 fork_when_denied: cfg.loop_cfg.fork_when_denied,
1551 setup: cfg.loop_cfg.setup.clone(),
1552 fork: OnceLock::new(),
1553 viewer: OnceLock::new(),
1554 checkpoints: Mutex::new(BTreeMap::new()),
1555 writes: WriteStats::default(),
1556 };
1557 repo.self_exclude();
1558 Ok(repo)
1559 }
1560
1561 fn self_exclude(&self) {
1569 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1570 let git_dir = git_dir.trim();
1571 if git_dir.is_empty() {
1572 return;
1573 }
1574 let path = Path::new(git_dir).join("info").join("exclude");
1575 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1576
1577 let wanted = [
1582 format!("/{WORKTREE_DIR}/"),
1583 format!("/{STATE_DIR}/"),
1584 format!("{RECOVERY_MARKER_PREFIX}*"),
1585 ];
1586 let missing: Vec<&String> = wanted
1587 .iter()
1588 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1589 .collect();
1590 if missing.is_empty() {
1591 return;
1592 }
1593
1594 use std::io::Write;
1595 if let Some(parent) = path.parent() {
1596 let _ = std::fs::create_dir_all(parent);
1597 }
1598 let mut block = String::new();
1599 if !existing.is_empty() && !existing.ends_with('\n') {
1600 block.push('\n');
1601 }
1602 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1603 for line in missing {
1604 block.push_str(line);
1605 block.push('\n');
1606 }
1607 if let Ok(mut file) = std::fs::OpenOptions::new()
1608 .create(true)
1609 .append(true)
1610 .open(&path)
1611 {
1612 let _ = file.write_all(block.as_bytes());
1613 }
1614 }
1615
1616 pub fn root(&self) -> &Path {
1617 &self.root
1618 }
1619
1620 pub(crate) fn write_summary(&self) -> WriteSummary {
1621 WriteSummary {
1622 attempted: self.writes.attempted.load(Ordering::Relaxed),
1623 failed: self.writes.failed.load(Ordering::Relaxed),
1624 }
1625 }
1626
1627 pub(crate) fn record_write<T, E>(
1628 &self,
1629 result: std::result::Result<T, E>,
1630 ) -> std::result::Result<T, E> {
1631 self.record_write_outcome(result.is_err());
1632 result
1633 }
1634
1635 pub(crate) fn record_failed_write<T, E>(
1636 &self,
1637 result: std::result::Result<T, E>,
1638 ) -> std::result::Result<T, E> {
1639 if result.is_err() {
1640 self.record_write_outcome(true);
1641 }
1642 result
1643 }
1644
1645 fn record_write_outcome(&self, failed: bool) {
1646 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1647 if failed {
1648 self.writes.failed.fetch_add(1, Ordering::Relaxed);
1649 }
1650 }
1651
1652 pub fn clean(&self, text: &str) -> Result<String> {
1658 let out = style::scrub(text, &self.style);
1659 let bad = style::violations(&out, &self.style);
1660 if !bad.is_empty() {
1661 bail!(
1662 "style gate could not clean text ({}): {}",
1663 bad.join(", "),
1664 style::clip(&out, 300)
1665 );
1666 }
1667 Ok(out)
1668 }
1669
1670 pub fn clean_body(&self, text: &str) -> Result<String> {
1672 self.clean(&style::body(text, &self.style))
1673 }
1674
1675 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1677 self.clean(&style::issue_body(text, &self.style))
1678 }
1679
1680 pub fn clean_title(&self, text: &str) -> Result<String> {
1691 Ok(style::title(&self.clean(text)?, &self.style))
1692 }
1693
1694 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1695 let title = self.record_failed_write(self.clean_title(text))?;
1696 if title.trim().is_empty() {
1697 return self.record_failed_write(Err(spar_err!(
1698 "nothing left of the title after cleaning it"
1699 )));
1700 }
1701 Ok(title)
1702 }
1703
1704 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1705 if self.followups == Followups::Issues {
1706 self.clean_nonempty_title_for_write(text)
1707 } else {
1708 self.clean_title(text)
1709 }
1710 }
1711
1712 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1715 ExecOpts::new()
1716 .cwd(cwd.unwrap_or(&self.root))
1717 .check(check)
1718 .timeout_secs(600)
1719 }
1720
1721 pub fn git(&self, args: &[&str]) -> Result<String> {
1722 self.git_at(None, args)
1723 }
1724
1725 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1726 let argv = git_without_maintenance_argv(args);
1727 proc::run(&argv, &self.git_opts(cwd, true))
1728 }
1729
1730 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1736 let argv = git_without_automation_argv(args);
1737 proc::run(
1738 &argv,
1739 &self.git_opts(Some(cwd), true).stop_descendants(true),
1740 )
1741 }
1742
1743 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1744 let argv = git_without_automation_argv(args);
1745 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1746 .map(|output| output.ok())
1747 }
1748
1749 pub fn git_try(&self, args: &[&str]) -> String {
1751 self.git_try_at(None, args)
1752 }
1753
1754 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1755 let argv = git_without_maintenance_argv(args);
1756 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1757 }
1758
1759 pub fn default_branch(&self, configured: &str) -> String {
1762 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1763 match refname.trim().rsplit('/').next() {
1764 Some(name) if !name.is_empty() => name.to_string(),
1765 _ => configured.to_string(),
1766 }
1767 }
1768
1769 pub fn branch_for_issue(&self, issue: i64) -> String {
1777 format!("{}issue-{issue}", self.branch_prefix)
1778 }
1779
1780 pub fn branch_for_pr(&self, number: i64) -> String {
1781 format!("{}pr-{number}", self.branch_prefix)
1782 }
1783
1784 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1793 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1794 }
1795
1796 fn ledger_path(&self) -> PathBuf {
1797 self.root.join(STATE_DIR).join("branches.json")
1798 }
1799
1800 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1801 std::fs::read_to_string(self.ledger_path())
1802 .ok()
1803 .and_then(|text| serde_json::from_str(&text).ok())
1804 .unwrap_or_default()
1805 }
1806
1807 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1808 let mut data = self.known_branches();
1809 data.insert(
1810 branch.to_string(),
1811 BranchRecord {
1812 kind: kind.to_string(),
1813 number,
1814 },
1815 );
1816 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1817 logdim!("could not record branch {branch}: {e}");
1818 }
1819 }
1820
1821 pub fn forget_branch(&self, branch: &str) {
1822 let mut data = self.known_branches();
1823 if data.remove(branch).is_none() {
1824 return;
1825 }
1826 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1827 logdim!("could not update the branch record: {e}");
1828 }
1829 }
1830
1831 fn worktree_path(&self, name: &str) -> PathBuf {
1834 self.root.join(WORKTREE_DIR).join(name)
1835 }
1836
1837 fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
1844 let argv = self.setup.argv();
1845 if argv.is_empty() {
1846 return Ok(());
1847 }
1848 let command = self.setup.describe();
1849 logdim!("setting up the {label}: {command}");
1850 let out = proc::exec(
1851 &argv,
1852 &ExecOpts::new()
1853 .cwd(path)
1854 .check(false)
1855 .timeout_secs(SETUP_TIMEOUT_SECS)
1856 .stop_descendants(true),
1857 )
1858 .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
1859 if !out.ok() {
1860 let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
1861 bail!(
1862 "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
1863 work in it.\n{}",
1864 path.display(),
1865 style::clip(text.trim(), 4000)
1866 );
1867 }
1868 Ok(())
1869 }
1870
1871 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1873 let branch = self.branch_for_issue(issue);
1874 let path = self.worktree_path(&format!("issue-{issue}"));
1875
1876 self.refuse_issue_branch_rebuild(issue, base)?;
1877 self.refuse_dirty_worktree(
1878 &path,
1879 &format!("worktree for issue #{issue}"),
1880 Disposable::BuildOutput,
1881 )?;
1882
1883 if !self.branch_deletion_is_safe(&branch)? {
1884 bail!(
1885 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1886 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1887 before retrying."
1888 );
1889 }
1890
1891 if !self.remove_worktree_at(&path)? {
1892 bail!(
1893 "the existing worktree for issue #{issue} could not be removed safely. Its \
1894 branch was kept."
1895 );
1896 }
1897 if !self.delete_branch_if_safe(&branch)? {
1898 bail!(
1899 "the existing branch {branch} changed or remained checked out while its \
1900 worktree was being rebuilt. It was kept."
1901 );
1902 }
1903
1904 if let Some(parent) = path.parent() {
1905 std::fs::create_dir_all(parent)
1906 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1907 }
1908
1909 let path_str = path.display().to_string();
1910 let remote_start = format!("origin/{base}");
1911 let created = self
1912 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1913 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1914
1915 created.map_err(|e| {
1918 spar_err!(
1919 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1920 and does `origin` exist?",
1921 e.last_line()
1922 )
1923 })?;
1924 self.record_branch(&branch, "issue", issue);
1925 self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
1926 Ok((path, branch))
1927 }
1928
1929 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1936 let branch = self.branch_for_issue(issue);
1937 let base_remote_ref = format!("refs/heads/{base}");
1938 let base_tracking_ref = format!("refs/remotes/origin/{base}");
1939 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1940 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1941 .map_err(|e| {
1942 spar_err!(
1943 "could not refresh origin/{base} before checking issue #{issue}: {}",
1944 e.last_line()
1945 )
1946 })?;
1947
1948 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1949 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1950 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1951 bail!(
1952 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1953 pull request accounts for them. Rebuilding it would force push over that \
1954 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1955 it, or delete it with `git push origin --delete {branch}` if the remote \
1956 branch is no longer needed."
1957 );
1958 }
1959 }
1960
1961 if self.fork_when_denied && self.origin_refuses_push() {
1964 match self.existing_fork() {
1965 Ok(Some(fork)) => {
1966 self.fork_branch_lease(&fork, &branch, base)?;
1967 }
1968 Ok(None) => {}
1969 Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
1970 }
1971 }
1972
1973 let local_ref = format!("refs/heads/{branch}");
1974 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1975 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1976 let recorded_pr = self
1977 .known_branches()
1978 .get(&branch)
1979 .is_some_and(|record| record.kind == "pr");
1980 let preserved = ahead == 0
1981 || if recorded_pr {
1982 self.local_branch_is_preserved(&branch)?
1983 } else {
1984 self.pull_request_holds(&branch, &local_ref, base)
1985 };
1986 if !preserved {
1987 let listed = self
1988 .commit_lines(&self.root, &local_ref, base)
1989 .iter()
1990 .map(|line| format!(" {line}"))
1991 .collect::<Vec<_>>()
1992 .join("\n");
1993 bail!(
1994 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1995 no pull request preserves them. Rebuilding it would delete the only copy.\n\
1996 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1997 it, or delete it with `git branch -D {branch}` if it is stale."
1998 );
1999 }
2000 }
2001 Ok(())
2002 }
2003
2004 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2005 let live_ref = format!("refs/heads/{branch}");
2006 let tracking_ref = format!("refs/remotes/origin/{branch}");
2007 let listed = self
2008 .git(&["ls-remote", "--heads", "origin", &live_ref])
2009 .map_err(|e| {
2010 spar_err!(
2011 "could not verify whether origin/{branch} still exists: {}",
2012 e.last_line()
2013 )
2014 })?;
2015
2016 if remote_head_oid(&listed, &live_ref)?.is_some() {
2017 let refspec = format!("+{live_ref}:{tracking_ref}");
2018 self.git(&["fetch", "--no-tags", "origin", &refspec])
2019 .map_err(|e| {
2020 spar_err!(
2021 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2022 e.last_line()
2023 )
2024 })?;
2025 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2026 bail!("origin/{branch} was fetched but its tracking ref is missing");
2027 }
2028 return Ok(Some(tracking_ref));
2029 }
2030
2031 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2032 return Ok(None);
2033 }
2034 let expected = self
2035 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2036 .trim()
2037 .to_string();
2038 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2039 .map_err(|e| {
2040 spar_err!(
2041 "could not discard stale origin/{branch} tracking ref safely: {}",
2042 e.last_line()
2043 )
2044 })?;
2045 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2046 bail!(
2047 "origin/{branch} changed while its stale tracking ref was being removed. It was \
2048 kept."
2049 );
2050 }
2051 Ok(None)
2052 }
2053
2054 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2063 self.prs_for_branch(branch)
2064 .iter()
2065 .any(|pr| self.pr_head_holds(pr.number, refname, base))
2066 }
2067
2068 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2069 let head = format!("refs/spar/pr-head/{number}");
2070 let refspec = format!("+refs/pull/{number}/head:{head}");
2071 if self.git(&["fetch", "origin", &refspec]).is_err() {
2072 return false;
2073 }
2074 let held = self.commits_held_by(refname, base, &head);
2075 self.git_try(&["update-ref", "-d", &head]);
2076 held
2077 }
2078
2079 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2080 let argv = vec![
2081 "git".to_string(),
2082 "merge-base".to_string(),
2083 "--is-ancestor".to_string(),
2084 older.to_string(),
2085 newer.to_string(),
2086 ];
2087 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2088 match out.code {
2089 0 => Ok(true),
2090 1 => Ok(false),
2091 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2092 }
2093 }
2094
2095 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2096 let head = format!("refs/spar/pr-head/{number}");
2097 let refspec = format!("+refs/pull/{number}/head:{head}");
2098 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2099 spar_err!(
2100 "could not verify the immutable head of PR #{number}: {}",
2101 e.last_line()
2102 )
2103 })?;
2104 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2105 self.git_try(&["update-ref", "-d", &head]);
2106 held
2107 }
2108
2109 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2110 let text = self.gh(&[
2111 "pr",
2112 "list",
2113 "--head",
2114 branch,
2115 "--state",
2116 "all",
2117 "--json",
2118 "number,url,title",
2119 ])?;
2120 serde_json::from_str(text.trim())
2121 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2122 }
2123
2124 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2125 let branch_ref = format!("refs/heads/{branch}");
2126 if record.kind == "pr" {
2127 return self.pr_head_contains_checked(record.number, &branch_ref);
2128 }
2129 let prs = self.branch_prs_checked(branch)?;
2130 if prs.is_empty() {
2131 return Ok(false);
2132 }
2133 for pr in prs {
2134 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2135 return Ok(true);
2136 }
2137 }
2138 Ok(false)
2139 }
2140
2141 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2142 let local_ref = format!("refs/heads/{branch}");
2143 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2144 return Ok(true);
2145 }
2146 let oid = self
2147 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2148 .trim()
2149 .to_string();
2150 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2151 if !durable_tip {
2152 let remote_ref = format!("refs/heads/{branch}");
2153 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2154 durable_tip = remote.lines().any(|line| {
2155 line.split_whitespace()
2156 .next()
2157 .is_some_and(|remote_oid| remote_oid == oid)
2158 });
2159 }
2160 if !durable_tip {
2161 if let Some(record) = self.known_branches().get(branch) {
2162 durable_tip = self.branch_is_preserved_checked(branch, record)?;
2163 }
2164 }
2165 if !durable_tip {
2166 return Ok(false);
2167 }
2168 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2169 }
2170
2171 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2175 let local_ref = format!("refs/heads/{branch}");
2176 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2177 return Ok(true);
2178 }
2179 let expected = self
2180 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2181 .trim()
2182 .to_string();
2183 if !self.branch_deletion_is_safe(branch)? {
2184 return Ok(false);
2185 }
2186 let checked_out = self
2187 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2188 .lines()
2189 .any(|line| line == format!("branch {local_ref}"));
2190 if checked_out {
2191 return Ok(false);
2192 }
2193 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2194 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2195 }
2196
2197 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2198 let local_ref = review_ref(number);
2199 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2200 return Ok(true);
2201 }
2202 let oid = self
2203 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2204 .trim()
2205 .to_string();
2206 if !self.pr_head_contains_checked(number, &local_ref)? {
2207 return Ok(false);
2208 }
2209 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2210 }
2211
2212 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2213 let local_ref = review_ref(number);
2214 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2215 return Ok(true);
2216 }
2217 let expected = self
2218 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2219 .trim()
2220 .to_string();
2221 if !self.review_ref_deletion_is_safe(number)? {
2222 return Ok(false);
2223 }
2224 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2225 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2226 }
2227
2228 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2232 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2233 self.git_try(&["rev-list", "--count", &range, "--not", other])
2234 .trim()
2235 == "0"
2236 }
2237
2238 pub fn worktree_remove(&self, issue: i64) -> bool {
2239 let path = self.worktree_path(&format!("issue-{issue}"));
2240 match self.remove_worktree_at(&path) {
2241 Ok(removed) => removed,
2242 Err(error) => {
2243 logdim!(
2244 "kept {} because removal did not reach a confirmed quiet point: {}",
2245 path.display(),
2246 error.last_line()
2247 );
2248 false
2249 }
2250 }
2251 }
2252
2253 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2258 let wanted = std::fs::canonicalize(path)
2259 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2260 if wanted != path {
2265 return Ok(false);
2266 }
2267 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2268 let raw = PathBuf::from(value.trim());
2269 let joined = if raw.is_absolute() {
2270 raw
2271 } else {
2272 cwd.join(raw)
2273 };
2274 std::fs::canonicalize(&joined)
2275 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2276 };
2277 let expected =
2278 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2279 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2280 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2281 let expected = resolve(&self.root, &expected)?;
2282 let actual = resolve(path, &actual)?;
2283 let top = resolve(path, &top)?;
2284 Ok(expected == actual && top == wanted)
2285 }
2286
2287 fn remove_worktree_at_with_force(
2293 &self,
2294 path: &Path,
2295 force: bool,
2296 disposable: Disposable,
2297 ) -> Result<bool> {
2298 let existed = path.exists();
2299 if path.exists() {
2300 match self.worktree_belongs_to_repo(path) {
2301 Ok(true) => {}
2302 Ok(false) => {
2303 logdim!(
2304 "kept {} because it is not a worktree owned by this repository",
2305 path.display()
2306 );
2307 return Ok(false);
2308 }
2309 Err(e) => {
2310 logdim!(
2311 "kept {} because its worktree ownership could not be verified: {}",
2312 path.display(),
2313 e.last_line()
2314 );
2315 return Ok(false);
2316 }
2317 }
2318 if !force {
2319 match self.has_recoverable_work(path, disposable) {
2320 Ok(true) => {
2321 logdim!(
2322 "kept {} because it contains recoverable files or repository state",
2323 path.display()
2324 );
2325 return Ok(false);
2326 }
2327 Err(e) => {
2328 logdim!(
2329 "kept {} because its recoverable state could not be checked: {}",
2330 path.display(),
2331 e.last_line()
2332 );
2333 return Ok(false);
2334 }
2335 Ok(false) => {}
2336 }
2337 }
2338 }
2339 let path_str = path.display().to_string();
2345 let command_ok = if force || disposable == Disposable::ReviewOutput {
2346 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2347 } else {
2348 self.git_try_without_automation(&["worktree", "remove", &path_str])?
2349 };
2350 Ok((command_ok || !existed) && !path.exists())
2351 }
2352
2353 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2354 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2355 }
2356
2357 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2359 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2360 }
2361
2362 fn remove_worktree_at_force(&self, path: &Path) -> bool {
2364 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2365 Ok(removed) => removed,
2366 Err(error) => {
2367 logdim!(
2368 "kept {} because removal did not reach a confirmed quiet point: {}",
2369 path.display(),
2370 error.last_line()
2371 );
2372 false
2373 }
2374 }
2375 }
2376
2377 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2382 if path.exists() && !self.worktree_belongs_to_repo(path)? {
2383 bail!(
2384 "{} is not a worktree owned by this repository, so it was kept",
2385 path.display()
2386 );
2387 }
2388 if path.exists() && self.has_recoverable_work(path, disposable)? {
2389 bail!(
2390 "the verified worktree at {} contains recoverable files or repository state. It \
2391 was kept.",
2392 path.display()
2393 );
2394 }
2395 let path_str = path.display().to_string();
2396 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2397 .map_err(|e| {
2398 e.with_message(format!(
2399 "could not remove the verified worktree at {}: {}. It was kept.",
2400 path.display(),
2401 e.last_line()
2402 ))
2403 })?;
2404 Ok(!path.exists())
2405 }
2406
2407 fn refuse_dirty_worktree(
2408 &self,
2409 path: &Path,
2410 label: &str,
2411 disposable: Disposable,
2412 ) -> Result<()> {
2413 if !path.is_dir() {
2414 return Ok(());
2415 }
2416 let has_files = std::fs::read_dir(path)
2417 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2418 .next()
2419 .is_some();
2420 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2421 spar_err!(
2422 "could not verify whether the existing {label} at {} belongs to this repository, \
2423 so it was kept: {}",
2424 path.display(),
2425 e.last_line()
2426 )
2427 })?;
2428 if !owned {
2429 if has_files {
2430 bail!(
2431 "the existing {label} at {} is not a worktree owned by \
2432 this repository. Refusing to remove it.",
2433 path.display()
2434 );
2435 }
2436 return Ok(());
2437 }
2438 if !path.join(".git").exists() {
2439 if has_files {
2440 bail!(
2441 "the existing {label} at {} is not a readable Git worktree and is not empty. \
2442 Refusing to remove it.",
2443 path.display()
2444 );
2445 }
2446 return Ok(());
2447 }
2448 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2449 spar_err!(
2450 "could not verify whether the existing {label} at {} is clean, so it was kept: \
2451 {}",
2452 path.display(),
2453 e.last_line()
2454 )
2455 })?;
2456 if dirty {
2457 bail!(
2458 "the existing {label} contains uncommitted changes or ignored files at {}. \
2459 Rebuilding it would delete those files.\nCommit or recover them before running this \
2460 command again, or use `spar clean --all` if they are not needed.",
2461 path.display()
2462 );
2463 }
2464 Ok(())
2465 }
2466
2467 pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2470 let head = pr.head_ref_name.clone();
2471 if head.trim().is_empty() {
2472 bail!("PR #{} has no head branch to check out", pr.number);
2473 }
2474 let path = self.worktree_path(&format!("pr-{}", pr.number));
2475 let local = self.branch_for_pr(pr.number);
2476
2477 let unfetched = |e: crate::error::SparError| {
2478 spar_err!(
2479 "could not fetch the branch behind PR #{}: {}",
2480 pr.number,
2481 e.last_line()
2482 )
2483 };
2484 let (start, start_ref, shown) = match target {
2487 PushTarget::Origin => {
2488 self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2489 let start = format!("origin/{head}");
2490 (start.clone(), format!("refs/remotes/origin/{head}"), start)
2491 }
2492 PushTarget::Fork(fork) => {
2495 let start_ref = format!("refs/spar/fork/{head}");
2496 let refspec = format!("+refs/heads/{head}:{start_ref}");
2497 self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2498 .map_err(unfetched)?;
2499 (start_ref.clone(), start_ref, target.describe(&head))
2500 }
2501 };
2502 let local_ref = format!("refs/heads/{local}");
2503 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2504 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2505 if unpushed > 0 {
2506 bail!(
2507 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2508 on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2509 {} and push or recover those commits before running this command again.",
2510 pr.number,
2511 path.display()
2512 );
2513 }
2514 }
2515 self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2516 self.refuse_dirty_worktree(
2517 &path,
2518 &format!("worktree for PR #{}", pr.number),
2519 Disposable::BuildOutput,
2520 )?;
2521 if !self.branch_deletion_is_safe(&local)? {
2522 bail!(
2523 "the existing branch {local} has a tip or reflog-only commit that no surviving \
2524 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2525 before retrying."
2526 );
2527 }
2528 if !self.remove_worktree_at(&path)? {
2529 bail!(
2530 "the existing worktree for PR #{} could not be removed safely. Its branch was \
2531 kept.",
2532 pr.number
2533 );
2534 }
2535 if !self.delete_branch_if_safe(&local)? {
2536 bail!(
2537 "the existing branch {local} changed or remained checked out while the PR \
2538 worktree was being rebuilt. It was kept."
2539 );
2540 }
2541
2542 let path_str = path.display().to_string();
2543 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2544 self.record_branch(&local, "pr", pr.number);
2545 self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2546 Ok((path, head))
2547 }
2548
2549 fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2552 if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2553 return Ok(());
2554 }
2555 let current =
2556 self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2557 if current.trim() != branch
2558 || self.has_uncommitted_changes(path)?
2559 || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2560 {
2561 return Ok(());
2562 }
2563 static NEXT: AtomicUsize = AtomicUsize::new(0);
2564 let (name, retained) = loop {
2565 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2566 let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2567 let retained = self.worktree_path(&name);
2568 if !retained
2569 .try_exists()
2570 .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2571 && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2572 {
2573 break (name, retained);
2574 }
2575 };
2576 self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2579 if let Err(error) = self.git_at_without_automation(
2580 &self.root,
2581 &[
2582 "worktree",
2583 "move",
2584 &path.display().to_string(),
2585 &retained.display().to_string(),
2586 ],
2587 ) {
2588 let rollback =
2591 self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2592 let detail = rollback
2593 .err()
2594 .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2595 .unwrap_or_default();
2596 bail!(
2597 "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2598 retained.display(),
2599 error.last_line()
2600 );
2601 }
2602 logdim!(
2603 "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2604 retained.display()
2605 );
2606 Ok(())
2607 }
2608
2609 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2619 let path = self.worktree_path(&format!("review-{number}"));
2620 let local_ref = review_ref(number);
2621 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2622
2623 self.refuse_review_worktree_changes(number)?;
2624
2625 if let Some(parent) = path.parent() {
2626 std::fs::create_dir_all(parent)
2627 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2628 }
2629 if !self.remove_review_worktree_at(&path)? {
2634 bail!(
2635 "the existing review worktree for PR #{number} could not be removed safely. Its \
2636 reference was kept."
2637 );
2638 }
2639
2640 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2641 spar_err!(
2642 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2643 every pull request, so this usually means the number is wrong or `origin` does \
2644 not point at the repository the PR is on.",
2645 e.last_line()
2646 )
2647 })?;
2648
2649 let path_str = path.display().to_string();
2650 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2651 self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2652 Ok(path)
2653 }
2654
2655 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2656 let path = self.worktree_path(&format!("review-{number}"));
2657 if !path.is_dir() {
2658 return Ok(());
2659 }
2660 let local_ref = review_ref(number);
2661 if !self.worktree_belongs_to_repo(&path)? {
2662 return Ok(());
2663 }
2664 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2665 bail!(
2666 "the existing review worktree for PR #{number} has no recorded head at \
2667 {local_ref}. Refusing to rebuild {}.",
2668 path.display()
2669 );
2670 }
2671 let worktree_head = self.head_oid_checked(&path)?;
2672 let recorded_head = self
2673 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2674 .trim()
2675 .to_string();
2676 if worktree_head != recorded_head {
2677 bail!(
2678 "the existing review worktree for PR #{number} has a local commit that is not on \
2679 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2680 {} before retrying.",
2681 path.display()
2682 );
2683 }
2684 self.report_recovery_markers(&path);
2685 self.refuse_dirty_worktree(
2686 &path,
2687 &format!("review worktree for PR #{number}"),
2688 Disposable::ReviewOutput,
2689 )?;
2690 Ok(())
2691 }
2692
2693 fn report_recovery_markers(&self, path: &Path) {
2701 let Ok(entries) = std::fs::read_dir(path) else {
2702 return;
2703 };
2704 for entry in entries.flatten() {
2705 let name = entry.file_name();
2706 let Some(name) = name.to_str() else { continue };
2707 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2708 continue;
2709 }
2710 match std::fs::read_to_string(entry.path()) {
2711 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2712 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2713 }
2714 }
2715 }
2716
2717 pub fn worktree_for_split(
2730 &self,
2731 parent: i64,
2732 index: usize,
2733 start: &str,
2734 ) -> Result<(PathBuf, String)> {
2735 let slot = self.free_split_slot(parent, index)?;
2736 let branch = format!("{}{slot}", self.branch_prefix);
2737 let path = self.worktree_path(&slot);
2738
2739 if let Some(dir) = path.parent() {
2740 std::fs::create_dir_all(dir)
2741 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2742 }
2743 self.refuse_dirty_worktree(
2744 &path,
2745 &format!("worktree for part {index} of PR #{parent}"),
2746 Disposable::BuildOutput,
2747 )?;
2748 if !self.remove_worktree_at(&path)? {
2752 bail!(
2753 "the existing worktree for part {index} of PR #{parent} could not be removed \
2754 safely. No branch was created."
2755 );
2756 }
2757
2758 let path_str = path.display().to_string();
2759 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2760 .map_err(|e| {
2761 spar_err!(
2762 "could not create a worktree for part {index} of #{parent}. {}",
2763 e.last_line()
2764 )
2765 })?;
2766 self.record_branch(&branch, "split", parent);
2769 self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
2770 Ok((path, branch))
2771 }
2772
2773 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2780 for attempt in 1..=SPLIT_SLOTS {
2781 let slot = split_slot(parent, index, attempt);
2782 let branch = format!("{}{slot}", self.branch_prefix);
2783 self.git_try(&["fetch", "origin", &branch]);
2784 if !self.rev_exists(&self.root, &branch)
2785 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2786 {
2787 return Ok(slot);
2788 }
2789 }
2790 bail!(
2791 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2792 names are all taken. Inspect the existing branches and child pull requests. Finish \
2793 recording the earlier split, or remove every retained local worktree and branch, \
2794 child pull request, and remote split branch before starting over.",
2795 self.branch_for_split(parent, index)
2796 )
2797 }
2798
2799 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2805 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2806 Ok(!self
2807 .git(&["ls-remote", "--heads", "origin", &pattern])?
2808 .trim()
2809 .is_empty())
2810 }
2811
2812 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2819 match self.branch_deletion_is_safe(branch) {
2820 Ok(true) => {}
2821 Ok(false) => {
2822 logdim!(
2823 "kept {branch} and {} because no surviving ref preserves its tip",
2824 dir.display()
2825 );
2826 return;
2827 }
2828 Err(error) => {
2829 logdim!(
2830 "kept {branch} and {} because preservation could not be verified: {}",
2831 dir.display(),
2832 error.last_line()
2833 );
2834 return;
2835 }
2836 }
2837 match self.remove_worktree_at(dir) {
2838 Ok(true) => match self.delete_branch_if_safe(branch) {
2839 Ok(true) => self.forget_branch(branch),
2840 Ok(false) => {
2841 logdim!("kept {branch} because its tip or reflog changed before deletion")
2842 }
2843 Err(error) => logdim!(
2844 "kept {branch} because deletion safety could not be rechecked: {}",
2845 error.last_line()
2846 ),
2847 },
2848 Ok(false) => {}
2849 Err(error) => logdim!(
2850 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2851 dir.display(),
2852 error.last_line()
2853 ),
2854 }
2855 }
2856
2857 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2863 let record = self.known_branches().get(branch).cloned();
2864 if record.is_none_or(|record| record.kind != "split") {
2865 logdim!("kept {branch} because no split branch record proves ownership");
2866 return false;
2867 }
2868 let local_ref = format!("refs/heads/{branch}");
2869 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2870 Ok(value) => value.trim().to_string(),
2871 Err(error) => {
2872 logdim!(
2873 "kept {branch} because its tip could not be checked: {}",
2874 error.last_line()
2875 );
2876 return false;
2877 }
2878 };
2879 if expected != disposable_head {
2880 logdim!("kept {branch} because it moved beyond the disposable slice");
2881 return false;
2882 }
2883 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2884 Ok(true) => {}
2885 Ok(false) => {
2886 logdim!(
2887 "kept {branch} because its reflog contains work outside the disposable slice"
2888 );
2889 return false;
2890 }
2891 Err(error) => {
2892 logdim!(
2893 "kept {branch} because its reflog could not be checked: {}",
2894 error.last_line()
2895 );
2896 return false;
2897 }
2898 }
2899 match self.head_oid_checked(dir) {
2900 Ok(head) if head == disposable_head => {}
2901 Ok(_) => {
2902 logdim!(
2903 "kept {branch} and {} because the worktree moved beyond the disposable slice",
2904 dir.display()
2905 );
2906 return false;
2907 }
2908 Err(error) => {
2909 logdim!(
2910 "kept {branch} and {} because its head could not be checked: {}",
2911 dir.display(),
2912 error.last_line()
2913 );
2914 return false;
2915 }
2916 }
2917 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2918 Ok(true) => {}
2919 Ok(false) => return false,
2920 Err(error) => {
2921 logdim!(
2922 "kept {branch} and {} because the disposable slice could not be verified: {}",
2923 dir.display(),
2924 error.last_line()
2925 );
2926 return false;
2927 }
2928 }
2929 if let Err(error) =
2930 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2931 {
2932 logdim!(
2933 "kept {branch} because its exact disposable tip could not be deleted: {}",
2934 error.last_line()
2935 );
2936 return false;
2937 }
2938 match self.exact_ref_exists_checked(&self.root, &local_ref) {
2939 Ok(false) => {
2940 self.forget_branch(branch);
2941 true
2942 }
2943 Ok(true) => {
2944 logdim!("kept {branch} because its ref still exists after deletion");
2945 false
2946 }
2947 Err(error) => {
2948 logdim!(
2949 "kept the branch record for {branch} because deletion could not be verified: {}",
2950 error.last_line()
2951 );
2952 false
2953 }
2954 }
2955 }
2956
2957 pub fn release_review_worktree(&self, number: i64) {
2958 let path = self.worktree_path(&format!("review-{number}"));
2959 match self.review_ref_deletion_is_safe(number) {
2960 Ok(true) => {}
2961 Ok(false) => {
2962 logdim!(
2963 "kept {} because no surviving ref preserves its review history",
2964 path.display()
2965 );
2966 return;
2967 }
2968 Err(error) => {
2969 logdim!(
2970 "kept {} because review history could not be verified: {}",
2971 path.display(),
2972 error.last_line()
2973 );
2974 return;
2975 }
2976 }
2977 match self.remove_worktree_at(&path) {
2978 Ok(true) => match self.delete_review_ref_if_safe(number) {
2979 Ok(true) => {}
2980 Ok(false) => logdim!(
2981 "kept {} because its review history changed before deletion",
2982 review_ref(number)
2983 ),
2984 Err(error) => logdim!(
2985 "kept {} because deletion safety could not be rechecked: {}",
2986 review_ref(number),
2987 error.last_line()
2988 ),
2989 },
2990 Ok(false) => {}
2991 Err(error) => logdim!(
2992 "kept {} because removal did not reach a confirmed quiet point: {}",
2993 path.display(),
2994 error.last_line()
2995 ),
2996 }
2997 }
2998
2999 pub(crate) fn release_review_worktree_checked(
3002 &self,
3003 number: i64,
3004 checkpoint: &WorktreeCheckpoint,
3005 ) -> Result<()> {
3006 let path = self.worktree_path(&format!("review-{number}"));
3007 self.require_unchanged_worktree(
3008 &path,
3009 checkpoint,
3010 &format!("review worktree for PR #{number}"),
3011 )?;
3012 if !self.review_ref_deletion_is_safe(number)? {
3013 bail!(
3014 "the review reference for PR #{number} has reflog-only recovery history. The \
3015 worktree and reference were kept."
3016 );
3017 }
3018 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3019 bail!(
3020 "the verified review worktree at {} could not be removed, so its reference was \
3021 kept",
3022 path.display()
3023 );
3024 }
3025 if !self.delete_review_ref_if_safe(number)? {
3026 bail!(
3027 "the review reference for PR #{number} changed before deletion. The reference was \
3028 kept."
3029 );
3030 }
3031 Ok(())
3032 }
3033
3034 pub fn release_pr_worktree(&self, number: i64) -> bool {
3035 let path = self.worktree_path(&format!("pr-{number}"));
3036 let local = self.branch_for_pr(number);
3037 match self.branch_deletion_is_safe(&local) {
3038 Ok(true) => {}
3039 Ok(false) => {
3040 logdim!(
3041 "kept {local} and {} because no surviving ref preserves its tip",
3042 path.display()
3043 );
3044 return false;
3045 }
3046 Err(error) => {
3047 logdim!(
3048 "kept {local} and {} because preservation could not be verified: {}",
3049 path.display(),
3050 error.last_line()
3051 );
3052 return false;
3053 }
3054 }
3055 match self.remove_worktree_at(&path) {
3056 Ok(true) => match self.delete_branch_if_safe(&local) {
3057 Ok(true) => {
3058 self.forget_branch(&local);
3059 true
3060 }
3061 Ok(false) => {
3062 logdim!("kept {local} because its tip or reflog changed before deletion");
3063 false
3064 }
3065 Err(error) => {
3066 logdim!(
3067 "kept {local} because deletion safety could not be rechecked: {}",
3068 error.last_line()
3069 );
3070 false
3071 }
3072 },
3073 Ok(false) => false,
3074 Err(error) => {
3075 logdim!(
3076 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3077 path.display(),
3078 error.last_line()
3079 );
3080 false
3081 }
3082 }
3083 }
3084
3085 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3096 let remote = format!("origin/{base}");
3097 if self.rev_exists(cwd, &remote) {
3098 return remote;
3099 }
3100 if self.rev_exists(cwd, base) {
3101 logdim!("origin/{base} does not resolve, comparing against local {base}");
3102 return base.to_string();
3103 }
3104 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3105 remote
3106 }
3107
3108 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3109 let spec = format!("{refname}^{{commit}}");
3110 !self
3111 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3112 .trim()
3113 .is_empty()
3114 }
3115
3116 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3117 let range = format!("{}..HEAD", self.base_ref(cwd, base));
3118 !self
3119 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3120 .trim()
3121 .is_empty()
3122 }
3123
3124 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3125 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3126 Ok(found.lines().any(|line| line.trim() == refname))
3127 }
3128
3129 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3130 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3131 count.trim().parse::<usize>().map_err(|e| {
3132 spar_err!(
3133 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3134 count.trim()
3135 )
3136 })
3137 }
3138
3139 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3140 let remote = format!("refs/remotes/origin/{base}");
3141 if self.exact_ref_exists_checked(cwd, &remote)? {
3142 return Ok(remote);
3143 }
3144 let local = format!("refs/heads/{base}");
3145 if self.exact_ref_exists_checked(cwd, &local)? {
3146 return Ok(local);
3147 }
3148 bail!("neither origin/{base} nor local branch {base} resolves")
3149 }
3150
3151 pub(crate) fn commit_count_checked(
3152 &self,
3153 cwd: &Path,
3154 refname: &str,
3155 base: &str,
3156 ) -> Result<usize> {
3157 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3158 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3159 count.trim().parse::<usize>().map_err(|e| {
3160 spar_err!(
3161 "git returned an invalid commit count for {range}: {:?} ({e})",
3162 count.trim()
3163 )
3164 })
3165 }
3166
3167 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3168 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3169 }
3170
3171 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3172 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3173 let head = head.trim().to_string();
3174 if head.is_empty() {
3175 bail!("git returned an empty HEAD for {}", cwd.display());
3176 }
3177 Ok(head)
3178 }
3179
3180 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3185 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3186 self.local_branch_is_preserved(branch.trim())
3187 }
3188
3189 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3192 let known = self.known_branches();
3193 let Some(record) = known.get(branch) else {
3194 return Ok(false);
3195 };
3196 self.branch_is_preserved_checked(branch, record)
3197 }
3198
3199 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3201 has_uncommitted_work(cwd)
3202 }
3203
3204 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3207 repository_has_recoverable_work_with(cwd, true, disposable)
3208 }
3209
3210 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3212 let attributes = attribute_state(cwd)?;
3213 Ok(WorktreeBaseline {
3214 attributes,
3215 ignored_untracked: ignored_untracked_state(cwd)?,
3216 git_state: safe_git_state(cwd)?,
3217 })
3218 }
3219
3220 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3223 let attributes = attribute_state(cwd)?;
3224 Ok(WorktreeCheckpoint {
3225 path: std::fs::canonicalize(cwd)
3226 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3227 attributes,
3228 git_state: safe_git_state(cwd)?,
3229 ignored_untracked: ignored_untracked_state(cwd)?,
3230 })
3231 }
3232
3233 pub(crate) fn require_unchanged_worktree(
3236 &self,
3237 cwd: &Path,
3238 checkpoint: &WorktreeCheckpoint,
3239 label: &str,
3240 ) -> Result<()> {
3241 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3242 crate::error::SparError::uncertain_write(format!(
3243 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3244 cwd.display()
3245 ))
3246 })?;
3247 if resolved != checkpoint.path {
3248 return Err(uncertain_worktree_change(
3249 cwd,
3250 format!(
3251 "the {label} moved from {} to {} during inspection. It was kept.",
3252 checkpoint.path.display(),
3253 resolved.display()
3254 ),
3255 ));
3256 }
3257 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3258 uncertain_worktree_change(
3259 cwd,
3260 format!(
3261 "could not verify attribute files in the {label} at {}: {}. It was kept.",
3262 cwd.display(),
3263 e.last_line()
3264 ),
3265 )
3266 })?;
3267 if attributes != checkpoint.attributes {
3268 return Err(uncertain_worktree_change(
3269 cwd,
3270 format!(
3271 "attribute files in the {label} at {} changed during inspection. It was \
3272 kept for recovery.",
3273 cwd.display()
3274 ),
3275 ));
3276 }
3277 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3278 uncertain_worktree_change(
3279 cwd,
3280 format!(
3281 "could not verify the Git state of the {label} at {}: {}. It was kept.",
3282 cwd.display(),
3283 e.last_line()
3284 ),
3285 )
3286 })?;
3287 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3288 uncertain_worktree_change(
3289 cwd,
3290 format!(
3291 "could not verify untracked files in the {label} at {}: {}. It was kept.",
3292 cwd.display(),
3293 e.last_line()
3294 ),
3295 )
3296 })?;
3297 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3298 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3302 let tracked = checkpoint.git_state.describe_difference(&git_state);
3303 let untracked = (!leavings.condemning.is_empty()).then(|| {
3304 format!(
3305 "untracked file(s) changed: {}",
3306 list_paths(&leavings.condemning)
3307 )
3308 });
3309 let Some(reason) = join_reasons(tracked, untracked) else {
3310 return Ok(());
3311 };
3312 Err(uncertain_worktree_change(
3313 cwd,
3314 format!(
3315 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3316 for recovery.",
3317 cwd.display()
3318 ),
3319 ))
3320 }
3321
3322 pub(crate) fn refuse_new_ignored_files(
3326 &self,
3327 cwd: &Path,
3328 baseline: &WorktreeBaseline,
3329 ) -> Result<()> {
3330 self.allow_ignored_files(cwd, baseline).map(drop)
3331 }
3332
3333 pub(crate) fn refuse_failed_edit_leavings(
3335 &self,
3336 cwd: &Path,
3337 baseline: &WorktreeBaseline,
3338 ) -> Result<()> {
3339 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3340 .map(drop)
3341 }
3342
3343 pub(crate) fn refuse_read_only_leavings(
3345 &self,
3346 cwd: &Path,
3347 baseline: &WorktreeBaseline,
3348 ) -> Result<()> {
3349 self.refuse_changed_attributes(cwd, baseline)?;
3350 let after = ignored_untracked_state(cwd).map_err(|e| {
3351 uncertain_worktree_change(
3352 cwd,
3353 format!(
3354 "could not verify untracked files in {} after a read-only call: {}. The \
3355 worktree was kept for recovery.",
3356 cwd.display(),
3357 e.last_line()
3358 ),
3359 )
3360 })?;
3361 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3362 if leavings.condemning.is_empty() {
3363 return Ok(());
3364 }
3365 Err(uncertain_worktree_change(
3366 cwd,
3367 format!(
3368 "the read-only call left untracked file(s) in {} that the project does not \
3369 ignore: {}. The worktree was kept for recovery.",
3370 cwd.display(),
3371 list_paths(&leavings.condemning)
3372 ),
3373 ))
3374 }
3375
3376 fn allow_ignored_files(
3379 &self,
3380 cwd: &Path,
3381 baseline: &WorktreeBaseline,
3382 ) -> Result<AllowedArtifacts> {
3383 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3384 }
3385
3386 fn check_new_ignored_files(
3387 &self,
3388 cwd: &Path,
3389 baseline: &WorktreeBaseline,
3390 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3391 ) -> Result<AllowedArtifacts> {
3392 self.refuse_changed_attributes(cwd, baseline)?;
3393 let after = ignored_untracked_state(cwd).map_err(|e| {
3394 uncertain_worktree_change(
3395 cwd,
3396 format!(
3397 "could not verify untracked files in {} after editing: {}. The worktree was \
3398 kept for recovery.",
3399 cwd.display(),
3400 e.last_line()
3401 ),
3402 )
3403 })?;
3404 let changed = baseline.ignored_untracked.changed_paths(&after);
3405 if changed.is_empty() {
3406 return Ok(AllowedArtifacts::default());
3407 }
3408 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3409 .into_iter()
3410 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3411 if changed.is_empty() {
3412 return Ok(AllowedArtifacts::split(ignored, &after));
3413 }
3414 let listed = list_paths(&changed);
3415 Err(uncertain_worktree_change(
3416 cwd,
3417 format!(
3418 "the editing call created or changed untracked or ignored file(s) in {} that \
3419 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3420 recovery.",
3421 cwd.display()
3422 ),
3423 ))
3424 }
3425
3426 pub(crate) fn refuse_changed_existing_untracked(
3431 &self,
3432 cwd: &Path,
3433 baseline: &WorktreeBaseline,
3434 ) -> Result<()> {
3435 self.check_changed_existing_untracked(cwd, baseline)
3436 .map(drop)
3437 }
3438
3439 fn allow_changed_ignored_artifacts(
3440 &self,
3441 cwd: &Path,
3442 baseline: &WorktreeBaseline,
3443 ) -> Result<AllowedArtifacts> {
3444 self.check_changed_existing_untracked(cwd, baseline)
3445 }
3446
3447 fn check_changed_existing_untracked(
3448 &self,
3449 cwd: &Path,
3450 baseline: &WorktreeBaseline,
3451 ) -> Result<AllowedArtifacts> {
3452 self.refuse_changed_attributes(cwd, baseline)?;
3453 let after = ignored_untracked_state(cwd).map_err(|e| {
3454 uncertain_worktree_change(
3455 cwd,
3456 format!(
3457 "could not verify existing untracked files in {} after editing: {}. The \
3458 worktree was kept for recovery.",
3459 cwd.display(),
3460 e.last_line()
3461 ),
3462 )
3463 })?;
3464 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3465 if changed.is_empty() {
3466 return Ok(AllowedArtifacts::default());
3467 }
3468 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3469 .into_iter()
3470 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3471 if changed.is_empty() {
3472 return Ok(AllowedArtifacts::split(ignored, &after));
3473 }
3474 let listed = list_paths(&changed);
3475 Err(uncertain_worktree_change(
3476 cwd,
3477 format!(
3478 "the editing call changed or deleted existing untracked file(s) in {}: \
3479 {listed}. The worktree was kept for recovery.",
3480 cwd.display()
3481 ),
3482 ))
3483 }
3484
3485 pub(crate) fn refuse_unrepresented_tracked_changes(
3492 &self,
3493 cwd: &Path,
3494 baseline: &WorktreeBaseline,
3495 ) -> Result<()> {
3496 self.refuse_changed_attributes(cwd, baseline)?;
3497 let after = safe_git_state(cwd).map_err(|e| {
3498 uncertain_worktree_change(
3499 cwd,
3500 format!(
3501 "could not verify tracked files in {} after editing: {}. The worktree was \
3502 kept for recovery.",
3503 cwd.display(),
3504 e.last_line()
3505 ),
3506 )
3507 })?;
3508 let mut changed = Vec::new();
3509 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3510 uncertain_worktree_change(
3511 cwd,
3512 format!(
3513 "could not record untracked files before verifying transformed content in {}: \
3514 {}. The worktree was kept for recovery.",
3515 cwd.display(),
3516 e.last_line()
3517 ),
3518 )
3519 })?;
3520 let mut filter_was_run = false;
3521 let mut filter_problem = None;
3522 let mut repositories: BTreeSet<PathBuf> =
3523 baseline.git_state.repositories.keys().cloned().collect();
3524 repositories.extend(after.repositories.keys().cloned());
3525 'repositories: for repository_path in repositories {
3526 let before_repository = baseline.git_state.repositories.get(&repository_path);
3527 let after_repository = after.repositories.get(&repository_path);
3528 if before_repository.is_none() || after_repository.is_none() {
3529 changed.push(repository_path.clone());
3530 continue;
3531 }
3532 if before_repository.map(|repository| &repository.gitlinks)
3533 != after_repository.map(|repository| &repository.gitlinks)
3534 {
3535 changed.push(repository_path.join("<gitlinks>"));
3536 }
3537 let mut paths = BTreeSet::new();
3538 if let Some(repository) = before_repository {
3539 paths.extend(repository.tracked.keys().cloned());
3540 }
3541 if let Some(repository) = after_repository {
3542 paths.extend(repository.tracked.keys().cloned());
3543 }
3544 for path in paths {
3545 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3546 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3547 let worktree_changed =
3548 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3549 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3550 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3551 if !worktree_changed {
3552 continue;
3553 }
3554 if !index_changed {
3555 changed.push(repository_path.join(&path));
3556 continue;
3557 }
3558 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3559 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3560 let Some(current_entry) = current else {
3561 continue;
3562 };
3563 let Some(current_worktree) = current_worktree else {
3564 continue;
3565 };
3566 let content_changed =
3567 before_worktree.map(|file| file.content) != Some(current_worktree.content);
3568 let mode_changed = before_worktree.map(|file| file.mode.as_str())
3569 != Some(current_worktree.mode.as_str());
3570 let repository = cwd.join(&repository_path);
3571 let represented_content = if content_changed {
3572 filter_was_run = true;
3573 let result =
3574 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
3575 self.refuse_changed_attributes(cwd, baseline)?;
3576 match result {
3577 Ok(expected) => expected == current_worktree.content,
3578 Err(error) => {
3579 filter_problem = Some(format!(
3580 "could not verify transformed content for {:?}: {}",
3581 repository_path.join(&path),
3582 error.last_line()
3583 ));
3584 false
3585 }
3586 }
3587 } else {
3588 true
3589 };
3590 let represented_mode =
3591 !mode_changed || current_worktree.mode == current_entry.index_mode;
3592 if !represented_content || !represented_mode {
3593 changed.push(repository_path.join(&path));
3594 }
3595 if filter_problem.is_some() {
3596 break 'repositories;
3597 }
3598 }
3599 }
3600 if filter_was_run {
3601 self.refuse_changed_attributes(cwd, baseline)?;
3602 let verified = safe_git_state(cwd).map_err(|e| {
3603 uncertain_worktree_change(
3604 cwd,
3605 format!(
3606 "could not recheck tracked files after verifying transformed content in \
3607 {}: {}. The worktree was kept for recovery.",
3608 cwd.display(),
3609 e.last_line()
3610 ),
3611 )
3612 })?;
3613 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3614 uncertain_worktree_change(
3615 cwd,
3616 format!(
3617 "could not recheck untracked files after verifying transformed content \
3618 in {}: {}. The worktree was kept for recovery.",
3619 cwd.display(),
3620 e.last_line()
3621 ),
3622 )
3623 })?;
3624 if verified != after
3625 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3626 {
3627 return Err(uncertain_worktree_change(
3628 cwd,
3629 "a content filter changed the worktree while SPAR verified the managed \
3630 commit. The worktree was kept for recovery.",
3631 ));
3632 }
3633 self.refuse_changed_existing_untracked(cwd, baseline)?;
3634 }
3635 if let Some(problem) = filter_problem {
3636 return Err(uncertain_worktree_change(
3637 cwd,
3638 format!("{problem}. The worktree was kept for recovery."),
3639 ));
3640 }
3641 if changed.is_empty() {
3642 return Ok(());
3643 }
3644 let listed = list_paths(&changed);
3645 Err(uncertain_worktree_change(
3646 cwd,
3647 format!(
3648 "the editing call changed tracked working-file bytes, modes, repositories, or \
3649 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3650 recovery."
3651 ),
3652 ))
3653 }
3654
3655 pub(crate) fn refuse_changed_attributes(
3656 &self,
3657 cwd: &Path,
3658 baseline: &WorktreeBaseline,
3659 ) -> Result<()> {
3660 let after = attribute_state(cwd).map_err(|e| {
3661 uncertain_worktree_change(
3662 cwd,
3663 format!(
3664 "could not verify attribute files in {} after editing: {}. The worktree was \
3665 kept for recovery.",
3666 cwd.display(),
3667 e.last_line()
3668 ),
3669 )
3670 })?;
3671 if after == baseline.attributes {
3672 return Ok(());
3673 }
3674 Err(uncertain_worktree_change(
3675 cwd,
3676 format!(
3677 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3678 refused to run a Git operation that could select a new external filter.",
3679 cwd.display()
3680 ),
3681 ))
3682 }
3683
3684 pub(crate) fn commit_pending_changes(
3689 &self,
3690 cwd: &Path,
3691 baseline: &WorktreeBaseline,
3692 preferred_subject: &str,
3693 fallback_subject: &str,
3694 ) -> Result<bool> {
3695 let mut artifacts = IgnoredArtifacts::default();
3696 self.refuse_changed_attributes(cwd, baseline)?;
3697 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3698 refuse_unsafe_index_flags(cwd)?;
3699 if !self.has_uncommitted_changes(cwd)? {
3700 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3701 artifacts.report(cwd);
3702 return Ok(false);
3703 }
3704 self.stage_managed_changes(cwd, baseline).map_err(|e| {
3705 e.with_message(format!(
3706 "could not stage changes in {}: {}",
3707 cwd.display(),
3708 e.last_line()
3709 ))
3710 })?;
3711 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3714 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3715 if !changed_gitlinks.is_empty() {
3716 let listed = list_paths(&changed_gitlinks);
3717 bail!(
3718 "the editing call added or changed a gitlink at {listed}. It was staged but not \
3719 committed because the referenced repository objects might exist only inside \
3720 this worktree. The worktree was kept for recovery."
3721 );
3722 }
3723 let mut subject = self.clean_title(preferred_subject)?;
3724 if subject.trim().is_empty() {
3725 subject = self.clean_title(fallback_subject)?;
3726 }
3727 self.commit_staged_changes(cwd, &subject).map_err(|e| {
3728 e.with_message(format!(
3729 "could not commit changes in {}: {}. The staged files were kept.",
3730 cwd.display(),
3731 e.last_line()
3732 ))
3733 })?;
3734 if has_tracked_or_staged_work(cwd)? {
3735 bail!(
3736 "the commit in {} left additional uncommitted files. They were kept for \
3737 recovery.",
3738 cwd.display()
3739 );
3740 }
3741 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3742 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3743 artifacts.report(cwd);
3744 Ok(true)
3745 }
3746
3747 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3748 let after = ignored_untracked_state(cwd)?;
3749 self.git_at_without_automation(cwd, &["add", "-u"])?;
3750 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3751 if paths.is_empty() {
3752 return Ok(());
3753 }
3754 let mut input = Vec::new();
3755 for path in paths {
3756 input.extend(os_str_bytes(path.as_os_str())?);
3757 input.push(0);
3758 }
3759 let argv = git_without_automation_argv(&[
3760 "--literal-pathspecs",
3761 "add",
3762 "--pathspec-from-file=-",
3763 "--pathspec-file-nul",
3764 ]);
3765 proc::run_with_input_bytes(
3766 &argv,
3767 &self.git_opts(Some(cwd), true).stop_descendants(true),
3768 &input,
3769 )?;
3770 Ok(())
3771 }
3772
3773 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3776 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3777 .map(|_| ())
3778 }
3779
3780 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3787 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3788 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3789 .trim()
3790 .parse()
3791 .unwrap_or(0)
3792 }
3793
3794 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3798 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3799 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3800 .lines()
3801 .map(str::to_string)
3802 .collect()
3803 }
3804
3805 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3820 let ancestor = self
3821 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3822 .is_ok();
3823 if !ancestor {
3824 return None;
3825 }
3826 let range = format!("{earlier}..{later}");
3827 Some(
3828 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3829 .lines()
3830 .map(str::to_string)
3831 .collect(),
3832 )
3833 }
3834
3835 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3838 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3839 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3840 .lines()
3841 .map(str::trim)
3842 .filter(|line| !line.is_empty())
3843 .map(str::to_string)
3844 .collect()
3845 }
3846
3847 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3863 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3864 self.git_try_at(
3865 Some(cwd),
3866 &["diff", "--name-only", "--no-renames", "-z", &range],
3867 )
3868 .split('\0')
3869 .filter(|path| !path.is_empty())
3870 .map(str::to_string)
3871 .collect()
3872 }
3873
3874 pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
3889 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3890 let argv = git_without_maintenance_argv(&[
3891 "-c",
3892 "core.quotePath=false",
3893 "diff",
3894 "--no-color",
3895 "--no-ext-diff",
3896 "--no-textconv",
3897 "--unified=3",
3898 "--inter-hunk-context=0",
3899 "--diff-algorithm=myers",
3900 "--indent-heuristic",
3901 "--submodule=short",
3902 "--find-renames",
3903 "--src-prefix=a/",
3904 "--dst-prefix=b/",
3905 &range,
3906 ]);
3907 let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
3908 new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
3909 }
3910
3911 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3914 self.git_try_at(
3915 Some(cwd),
3916 &[
3917 "diff",
3918 "--name-only",
3919 "--no-renames",
3920 "-z",
3921 &format!("{from}..{to}"),
3922 ],
3923 )
3924 .split('\0')
3925 .filter(|path| !path.is_empty())
3926 .map(str::to_string)
3927 .collect()
3928 }
3929
3930 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3933 let base_ref = self.base_ref(cwd, base);
3934 let out = self
3935 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3936 .map_err(|e| {
3937 spar_err!(
3938 "could not find where {refname} and {base_ref} diverged. {}",
3939 e.last_line()
3940 )
3941 })?;
3942 let sha = out.trim().to_string();
3943 if sha.is_empty() {
3944 bail!("{refname} and {base_ref} share no history");
3945 }
3946 Ok(sha)
3947 }
3948
3949 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3950 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3951 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3952 full.trim().to_string()
3953 }
3954
3955 pub fn rewrite_commits_if_needed(
3968 &self,
3969 cwd: &Path,
3970 base: &str,
3971 floor: Option<&str>,
3972 ) -> Result<()> {
3973 let base_ref = self.base_ref(cwd, base);
3974 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3975 self.report_commits_left_alone(cwd, &base_ref, floor);
3976
3977 let range = format!("{floor}..HEAD");
3978 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3979
3980 let offenders = raw
3981 .split('\x1e')
3982 .filter_map(|entry| entry.split_once('\0'))
3983 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3984 .count();
3985 if offenders == 0 {
3986 return Ok(());
3987 }
3988 logdim!("{offenders} commit message(s) violated style rules, rewriting");
3989
3990 let exe = self_binary()?;
3991 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3992
3993 let argv: Vec<String> = [
3994 "git",
3995 "filter-branch",
3996 "-f",
3997 "--msg-filter",
3998 &filter,
3999 &range,
4000 ]
4001 .iter()
4002 .map(|s| s.to_string())
4003 .collect();
4004 let opts = ExecOpts::new()
4005 .cwd(cwd)
4006 .check(false)
4007 .timeout_secs(600)
4008 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4009 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4010 .env(
4011 "SPAR_BAN_AI_ATTRIBUTION",
4012 bool_env(self.style.ban_ai_attribution),
4013 );
4014 let _ = proc::run(&argv, &opts);
4015
4016 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4017 if !style::violations(&after, &self.style).is_empty() {
4018 bail!(
4019 "commit messages still violate style rules after a rewrite in {}.",
4020 cwd.display()
4021 );
4022 }
4023 Ok(())
4024 }
4025
4026 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4031 if floor == base_ref {
4032 return;
4033 }
4034 let raw = self.git_try_at(
4035 Some(cwd),
4036 &[
4037 "log",
4038 &format!("{base_ref}..{floor}"),
4039 "--format=%H%x00%B%x1e",
4040 ],
4041 );
4042 let left: Vec<&str> = raw
4043 .split('\x1e')
4044 .filter_map(|entry| entry.split_once('\0'))
4045 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4046 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4047 .collect();
4048 if left.is_empty() {
4049 return;
4050 }
4051 logdim!(
4052 "{} commit message(s) already on the branch break the style rules and were left \
4053 alone: {}. spar does not rewrite commits it did not write.",
4054 left.len(),
4055 left.join(", ")
4056 );
4057 }
4058
4059 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4074 self.push_to(cwd, &PushTarget::Origin, branch, expected)
4075 }
4076
4077 pub fn push_to(
4079 &self,
4080 cwd: &Path,
4081 target: &PushTarget,
4082 branch: &str,
4083 expected: Option<&str>,
4084 ) -> Result<()> {
4085 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4086 if let Some(head) = expected {
4087 self.refuse_push_that_drops(cwd, head)?;
4088 }
4089 let pushed = self
4090 .git_push(cwd, target, branch, expected)
4091 .map_err(|e| push_error(target, branch, &e));
4092 self.record_write(pushed)
4093 }
4094
4095 pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4107 let origin = PushTarget::Origin;
4108 let refused = match self.git_push(cwd, &origin, branch, None) {
4109 Ok(()) => return self.record_write(Ok(origin)),
4110 Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4111 Err(e) => {
4112 let err = push_error(&origin, branch, &e);
4113 let err = if push_denial(&e.to_string()).is_some() {
4114 spar_err!(
4115 "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4116 instead."
4117 )
4118 } else {
4119 err
4120 };
4121 return self.record_write(Err(err));
4122 }
4123 };
4124 self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4125 }
4126
4127 fn publish_on_fork(
4128 &self,
4129 cwd: &Path,
4130 branch: &str,
4131 base: &str,
4132 refused: &crate::error::SparError,
4133 ) -> Result<PushTarget> {
4134 let fork = self.fork().map_err(|e| {
4135 spar_err!(
4136 "{} spar could not publish it from a fork instead: {}",
4137 push_error(&PushTarget::Origin, branch, refused),
4138 e.last_line()
4139 )
4140 })?;
4141 let lease = self.fork_branch_lease(&fork, branch, base)?;
4142 let refused = refused.to_string();
4143 log!(
4144 "{} Publishing {branch} from the fork {} instead.",
4145 push_denial(&refused).unwrap_or("origin refused the push."),
4146 fork.full_name
4147 );
4148 let target = PushTarget::Fork(fork);
4149 self.git_push(cwd, &target, branch, lease.as_deref())
4152 .map_err(|e| push_error(&target, branch, &e))?;
4153 Ok(target)
4154 }
4155
4156 fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4164 let live_ref = format!("refs/heads/{branch}");
4165 let unreadable = |e: crate::error::SparError| {
4166 spar_err!(
4167 "could not check whether the fork {} already has {branch}. {}",
4168 fork.full_name,
4169 e.last_line()
4170 )
4171 };
4172 let listed = self
4173 .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4174 .map_err(unreadable)?;
4175 let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4176 return Ok(None);
4177 };
4178 let local = format!("refs/spar/fork/{branch}");
4179 self.git(&[
4180 "fetch",
4181 "--no-tags",
4182 &fork.url,
4183 &format!("+{live_ref}:{local}"),
4184 ])
4185 .map_err(unreadable)?;
4186 let ahead = self.commit_count_checked(&self.root, &local, base);
4187 let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4188 self.git_try(&["update-ref", "-d", &local]);
4189 let ahead = ahead?;
4190 if !held {
4191 bail!(
4192 "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4193 and no pull request accounts for them. Publishing over it would lose that work.\n\
4194 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4195 run spar again.",
4196 fork.full_name,
4197 fork.url
4198 );
4199 }
4200 Ok(Some(tip))
4201 }
4202
4203 fn git_push(
4204 &self,
4205 cwd: &Path,
4206 target: &PushTarget,
4207 branch: &str,
4208 expected: Option<&str>,
4209 ) -> Result<()> {
4210 let refspec = format!("HEAD:refs/heads/{branch}");
4211 let lease = match (expected, target) {
4212 (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4213 (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4217 (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4218 };
4219 self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4220 .map(|_| ())
4221 }
4222
4223 fn fork(&self) -> Result<Fork> {
4230 match self.existing_fork() {
4231 Ok(Some(fork)) => return Ok(fork),
4232 Ok(None) => {}
4233 Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4234 }
4235 let answer = self
4236 .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4237 .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4238 let fork = fork_from_api(&answer, &self.origin_push_url())?;
4239 self.wait_for_fork(&fork)?;
4240 Ok(self.fork.get_or_init(|| fork).clone())
4241 }
4242
4243 fn existing_fork(&self) -> Result<Option<Fork>> {
4246 if let Some(fork) = self.fork.get() {
4247 return Ok(Some(fork.clone()));
4248 }
4249 let answer = self
4250 .gh(&[
4251 "api",
4252 "graphql",
4253 "-F",
4254 "owner={owner}",
4255 "-F",
4256 "name={repo}",
4257 "-f",
4258 "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4259 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4260 login } sshUrl url } } } }",
4261 "--jq",
4262 ".data.repository.forks.nodes",
4263 ])
4264 .map_err(|e| {
4265 spar_err!(
4266 "could not look for a fork of this repository. {}",
4267 e.last_line()
4268 )
4269 })?;
4270 let found = fork_from_listing(&answer, &self.origin_push_url())?;
4271 Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4272 }
4273
4274 fn origin_refuses_push(&self) -> bool {
4278 self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4279 .trim()
4280 == "false"
4281 }
4282
4283 fn origin_push_url(&self) -> String {
4284 self.git_try(&["remote", "get-url", "--push", "origin"])
4285 }
4286
4287 pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4292 if !pr.is_cross_repository {
4293 return Some(PushTarget::Origin);
4294 }
4295 let viewer = self.viewer_login().ok()?;
4296 own_fork_target(pr, viewer, &self.origin_push_url())
4297 }
4298
4299 fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4300 const TRIES: u32 = 30;
4301 let mut tries = 1;
4302 loop {
4303 match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4304 Ok(_) => return Ok(()),
4305 Err(e) if tries >= TRIES => bail!(
4306 "the fork {} could not be reached after {} seconds. {}",
4307 fork.full_name,
4308 TRIES * 2,
4309 e.last_line()
4310 ),
4311 Err(_) => {
4312 tries += 1;
4313 std::thread::sleep(std::time::Duration::from_secs(2));
4314 }
4315 }
4316 }
4317 }
4318
4319 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4327 let head = self.head_oid_checked(cwd)?;
4328 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4329 return Ok(());
4330 }
4331 bail!(
4332 "refusing to push {head} because it does not contain the reviewed head {expected}. \
4333 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4334 the intended commits on top of it before resuming.",
4335 cwd.display()
4336 )
4337 }
4338
4339 pub fn push_split_branch(
4348 &self,
4349 cwd: &Path,
4350 branch: &str,
4351 ) -> std::result::Result<(), SplitPushError> {
4352 let remote_ref = format!("refs/heads/{branch}");
4353 let lease = format!("--force-with-lease={remote_ref}:");
4354 let refspec = format!("HEAD:{remote_ref}");
4355 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4356 Ok(_) => Ok(()),
4357 Err(push_error) => {
4358 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4359 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4360 reconcile_failed_split_push(branch, push_error, local, remote)
4361 }
4362 };
4363 self.record_write(result)
4364 }
4365
4366 pub fn gh(&self, args: &[&str]) -> Result<String> {
4369 self.gh_at(None, args)
4370 }
4371
4372 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4373 let mut argv = vec!["gh".to_string()];
4374 argv.extend(args.iter().map(|s| s.to_string()));
4375 proc::run(
4376 &argv,
4377 &ExecOpts::new()
4378 .cwd(cwd.unwrap_or(&self.root))
4379 .timeout_secs(300),
4380 )
4381 }
4382
4383 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4389 let mut argv = vec!["gh".to_string()];
4390 argv.extend(args.iter().map(|s| s.to_string()));
4391 proc::run(
4392 &argv,
4393 &ExecOpts::new()
4394 .cwd(&self.root)
4395 .timeout_secs(300)
4396 .stdin(stdin),
4397 )
4398 }
4399
4400 pub fn gh_try(&self, args: &[&str]) -> String {
4401 let mut argv = vec!["gh".to_string()];
4402 argv.extend(args.iter().map(|s| s.to_string()));
4403 proc::run(
4404 &argv,
4405 &ExecOpts::new()
4406 .cwd(&self.root)
4407 .check(false)
4408 .timeout_secs(300),
4409 )
4410 .unwrap_or_default()
4411 }
4412
4413 pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4418 let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4419 for (name, value) in headers {
4420 argv.push("-H".to_string());
4421 argv.push(format!("{name}: {value}"));
4422 }
4423 argv.push(endpoint.to_string());
4424 let out = proc::exec(
4425 &argv,
4426 &ExecOpts::new()
4427 .cwd(&self.root)
4428 .check(false)
4429 .timeout_secs(300),
4430 )?;
4431 parse_api_response(&out.stdout)
4432 .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4433 }
4434
4435 pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4437 let text = self.gh(&[
4438 "pr",
4439 "list",
4440 "--state",
4441 "open",
4442 "--limit",
4443 "500",
4444 "--json",
4445 PR_SWEEP_FIELDS,
4446 ])?;
4447 serde_json::from_str(&text)
4448 .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4449 }
4450
4451 pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4453 let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4454 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4455 }
4456
4457 pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4462 let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4464 if slug.is_empty() || login.is_empty() {
4465 return None;
4466 }
4467 let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4468 let resp = self.gh_api_raw(&path, &[]).ok()?;
4469 match resp.status {
4470 200 => {
4471 let body: Value = serde_json::from_str(&resp.body).ok()?;
4472 Some(body.get("state")?.as_str()? == "active")
4473 }
4474 404 => Some(false),
4475 _ => None,
4476 }
4477 }
4478
4479 pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4486 let text = self
4487 .gh(&[
4488 "api",
4489 "graphql",
4490 "-F",
4491 "owner={owner}",
4492 "-F",
4493 "repo={repo}",
4494 "-F",
4495 &format!("number={number}"),
4496 "-f",
4497 &format!("query={REQUESTS_QUERY}"),
4498 ])
4499 .ok()?;
4500 parse_review_requests(&text)
4501 }
4502
4503 pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4505 if from.is_empty() || to.is_empty() {
4506 return None;
4507 }
4508 let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4509 self.gh_try(&["api", &path, "--jq", ".total_commits"])
4510 .trim()
4511 .parse()
4512 .ok()
4513 }
4514
4515 pub fn repo_name(&self) -> Option<String> {
4517 let name = self.gh_try(&[
4518 "repo",
4519 "view",
4520 "--json",
4521 "nameWithOwner",
4522 "--jq",
4523 ".nameWithOwner",
4524 ]);
4525 let name = name.trim();
4526 (!name.is_empty()).then(|| name.to_string())
4527 }
4528
4529 pub fn listen_state_path(&self) -> PathBuf {
4530 self.root.join(STATE_DIR).join("state").join("listen.json")
4531 }
4532
4533 pub fn listen_lock_path(&self) -> PathBuf {
4534 self.root.join(STATE_DIR).join("state").join("listen.lock")
4535 }
4536
4537 pub fn viewer_login(&self) -> Result<&str> {
4548 if let Some(login) = self.viewer.get() {
4549 return Ok(login);
4550 }
4551 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4552 let login = if !rest.trim().is_empty() {
4553 rest.trim().to_string()
4554 } else {
4555 self.gh(&[
4558 "api",
4559 "graphql",
4560 "-f",
4561 "query={ viewer { login } }",
4562 "--jq",
4563 ".data.viewer.login",
4564 ])
4565 .map_err(|e| {
4566 spar_err!(
4567 "could not find out who `gh` is authenticated as, so spar cannot tell its \
4568 own comments from anybody else's. {}\nRun `gh auth status`.",
4569 e.last_line()
4570 )
4571 })?
4572 .trim()
4573 .to_string()
4574 };
4575 if login.is_empty() {
4576 bail!("`gh` reported an empty login. Run `gh auth status`.");
4577 }
4578 Ok(self.viewer.get_or_init(|| login))
4579 }
4580
4581 pub fn read_issue(&self, number: i64) -> Result<Issue> {
4587 let text = self
4588 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4589 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4590 serde_json::from_str(&text)
4591 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4592 }
4593
4594 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4595 let mut issues = Vec::new();
4596 for number in numbers {
4597 let text = self
4598 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4599 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4600 let issue: Issue = serde_json::from_str(&text)
4601 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4602 if issue.is_closed() {
4603 crate::log!("issue #{number} is closed, skipping");
4604 continue;
4605 }
4606 issues.push(issue);
4607 }
4608 if issues.is_empty() {
4609 bail!("no open issues to work on");
4610 }
4611 Ok(issues)
4612 }
4613
4614 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4620 #[derive(Deserialize)]
4621 struct Row {
4622 number: i64,
4623 }
4624 let text = self.gh(&[
4625 kind,
4626 "list",
4627 "--state",
4628 "open",
4629 "--limit",
4630 &FETCH_CEILING.to_string(),
4631 "--json",
4632 "number",
4633 ])?;
4634 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4635 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4636 numbers.sort_unstable();
4637
4638 let noun = if kind == "issue" { "issues" } else { "PRs" };
4639 let found = numbers.len();
4640 if min_number > 0 {
4641 numbers.retain(|n| *n >= min_number);
4642 let skipped = found - numbers.len();
4643 if skipped > 0 {
4644 crate::log!("{skipped} open {noun} below #{min_number} skipped");
4645 }
4646 }
4647 if found >= FETCH_CEILING {
4648 crate::log!(
4649 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4650 considered."
4651 );
4652 }
4653 if numbers.len() > limit {
4654 crate::log!(
4655 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4656 explicitly.",
4657 numbers.len()
4658 );
4659 numbers.truncate(limit);
4660 }
4661 Ok(numbers)
4662 }
4663
4664 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4666 self.open_numbers("issue", limit, min_number)
4667 }
4668
4669 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4670 self.open_numbers("pr", limit, min_number)
4671 }
4672
4673 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4674 self.branch_prs(branch, "open").into_iter().next()
4675 }
4676
4677 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4680 let text = self.gh(&[
4681 "pr",
4682 "list",
4683 "--head",
4684 branch,
4685 "--base",
4686 base,
4687 "--state",
4688 "open",
4689 "--json",
4690 "number,url,title,baseRefName",
4691 ])?;
4692 pr_for_base(&text, branch, base)
4693 }
4694
4695 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4699 self.branch_prs(branch, "all")
4700 }
4701
4702 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4703 let text = self.gh_try(&[
4704 "pr",
4705 "list",
4706 "--head",
4707 branch,
4708 "--state",
4709 state,
4710 "--json",
4711 "number,url,title",
4712 ]);
4713 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4714 }
4715
4716 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4722 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4723 let text = self
4724 .gh(&[
4725 "api",
4726 &path,
4727 "--jq",
4728 "if .pull_request then \"pr\" else \"issue\" end",
4729 ])
4730 .map_err(|e| {
4731 spar_err!(
4732 "no issue or pull request #{number} in this repository. {}",
4733 e.last_line()
4734 )
4735 })?;
4736 match text.trim() {
4737 "pr" => Ok(ItemKind::Pr),
4738 "issue" => Ok(ItemKind::Issue),
4739 other => Err(spar_err!(
4740 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4741 )),
4742 }
4743 }
4744
4745 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4756 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4757 if let Some(pr) = mine {
4758 return Ok(Some(pr));
4759 }
4760 let listed = match self.gh(&[
4761 "pr",
4762 "list",
4763 "--state",
4764 "open",
4765 "--limit",
4766 &FETCH_CEILING.to_string(),
4767 "--json",
4768 "number,url,title,baseRefName,closingIssuesReferences",
4769 ]) {
4770 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4771 spar_err!("could not read the pull request list while looking for #{issue}")
4772 })?,
4773 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4774 };
4775 if listed.len() >= FETCH_CEILING {
4776 logwarn!(
4777 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
4778 );
4779 }
4780 let default = self.default_branch(base);
4781 if !base.eq_ignore_ascii_case(&default) {
4782 logwarn!(
4786 "#{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."
4787 );
4788 }
4789 Ok(pick_linked_pr(&listed, issue))
4790 }
4791
4792 fn pr_for_branch_failing_closed(
4794 &self,
4795 branch: &str,
4796 base: &str,
4797 issue: i64,
4798 ) -> Result<Option<PrRef>> {
4799 match self.try_pr_for_branch(branch, base) {
4800 Ok(found) => Ok(found),
4801 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4802 }
4803 }
4804
4805 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4814 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4815 return Err(spar_err!(
4816 "could not check whether a pull request already exists for #{issue}: {}. \
4817 Refusing to implement over the top of work that may already be open.",
4818 e.last_line()
4819 ));
4820 }
4821 logwarn!(
4822 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4823 whether a pull request already exists for it.",
4824 e.last_line()
4825 );
4826 Ok(())
4827 }
4828
4829 pub fn pr_view(&self, number: i64) -> Result<PrView> {
4830 let text = self.gh(&[
4831 "pr",
4832 "view",
4833 &number.to_string(),
4834 "--json",
4835 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
4836 isCrossRepository,headRepositoryOwner,headRepository",
4837 ])?;
4838 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4839 }
4840
4841 fn try_pr_state(&self, number: i64) -> Result<String> {
4842 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4843 serde_json::from_str::<Value>(text.trim())
4844 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4845 .get("state")
4846 .and_then(Value::as_str)
4847 .map(str::to_string)
4848 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4849 }
4850
4851 pub fn pr_state(&self, number: i64) -> String {
4852 self.try_pr_state(number).unwrap_or_default()
4853 }
4854
4855 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
4857 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
4858 let oid = serde_json::from_str::<Value>(&text)
4859 .ok()
4860 .and_then(|value| {
4861 value
4862 .get("headRefOid")
4863 .and_then(Value::as_str)
4864 .map(str::trim)
4865 .filter(|oid| !oid.is_empty())
4866 .map(str::to_string)
4867 })
4868 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
4869 Ok(oid)
4870 }
4871
4872 pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
4876 let pull = self.pr_head_oid(pr.number)?;
4877 let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
4878 None
4879 } else {
4880 let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
4881 let listed = self
4882 .git(&["ls-remote", "--heads", "origin", &remote_ref])
4883 .map_err(|e| {
4884 spar_err!(
4885 "could not read where {} is on origin: {}",
4886 pr.head_ref_name.trim(),
4887 e.last_line()
4888 )
4889 })?;
4890 remote_head_oid(&listed, &remote_ref)?
4891 };
4892 Ok(LiveHead { pull, branch })
4893 }
4894
4895 pub fn create_pr(
4900 &self,
4901 cwd: &Path,
4902 branch: &str,
4903 target: &PushTarget,
4904 base: &str,
4905 title: &str,
4906 body: &str,
4907 ) -> Result<PrRef> {
4908 let title = self.record_failed_write(self.clean_title(title))?;
4909 let body = self.record_failed_write(self.clean(body))?;
4910 let head = target.head(branch);
4911 let mut argv = vec![
4912 "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
4913 ];
4914 if self.drafts != Drafts::Never {
4915 argv.push("--draft");
4916 }
4917 let created = self.gh_at(Some(cwd), &argv);
4918 let found = self.try_pr_for_branch(branch, base);
4919 self.record_write(reconcile_pr_creation(branch, created, found))
4920 }
4921
4922 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
4923 let body = signed(&self.record_failed_write(self.clean(body))?);
4924 let comments = self.record_failed_write(self.try_issue_comments(number))?;
4925 if has_exact_comment(&comments, &body) {
4926 return Ok(());
4927 }
4928 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
4929 let result = match posted {
4930 Ok(_) => Ok(()),
4931 Err(post_error) => {
4932 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4933 }
4934 };
4935 self.record_write(result)
4936 }
4937
4938 fn pr_is_own(&self, number: i64) -> Result<bool> {
4942 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
4943 let author = serde_json::from_str::<Value>(&text)
4944 .ok()
4945 .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
4946 .unwrap_or_default();
4947 let viewer = self.viewer_login()?.to_string();
4948 Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
4949 }
4950
4951 pub fn submit_review(
4972 &self,
4973 number: i64,
4974 verdict: Option<crate::model::ReviewVerdict>,
4975 body: &str,
4976 commit: &str,
4977 comments: &[InlineComment],
4978 ) -> Result<()> {
4979 let own = match verdict {
4980 Some(_) => self.record_failed_write(self.pr_is_own(number))?,
4981 None => false,
4982 };
4983 let text = match (verdict, own) {
4984 (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
4985 _ => body.to_string(),
4986 };
4987 let body = signed(&self.record_failed_write(self.clean(&text))?);
4988 let mut inline = Vec::with_capacity(comments.len());
4989 for c in comments {
4990 inline.push(InlineComment {
4991 body: signed(&self.record_failed_write(self.clean(&c.body))?),
4992 ..c.clone()
4993 });
4994 }
4995 let viewer = self.record_failed_write(self.viewer_login())?.to_string();
4996 let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
4997 if has_viewer_review(&reviews, &viewer, &body, commit) {
4998 return Ok(());
4999 }
5000 let event = match (verdict, own) {
5001 (Some(v), false) => v.api_event(),
5002 _ => "COMMENT",
5003 };
5004 let result = if inline.is_empty() {
5005 self.post_review(number, &body, &viewer, commit, event)
5006 } else {
5007 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5008 let payload = review_payload(commit, event, &body, &inline).to_string();
5009 match self.gh_stdin(
5010 &["api", "-X", "POST", &path, "--input", "-", "--silent"],
5011 &payload,
5012 ) {
5013 Ok(_) => Ok(()),
5014 Err(post_error) if !refused(&post_error) => reconcile_review_post(
5015 number,
5016 &body,
5017 &viewer,
5018 commit,
5019 post_error,
5020 self.try_pr_reviews(number),
5021 ),
5022 Err(post_error) => match self.try_pr_reviews(number) {
5023 Ok(reviews) if !has_viewer_review(&reviews, &viewer, &body, commit) => {
5024 logwarn!(
5025 "the review of #{number} with its line comments did not go up ({}), \
5026 so it is going up with every finding in its body only",
5027 post_error.last_line()
5028 );
5029 self.post_review(number, &body, &viewer, commit, event)
5030 }
5031 reviews => {
5032 reconcile_review_post(number, &body, &viewer, commit, post_error, reviews)
5033 }
5034 },
5035 }
5036 };
5037 self.record_write(result)
5038 }
5039
5040 fn post_review(
5042 &self,
5043 number: i64,
5044 body: &str,
5045 viewer: &str,
5046 commit: &str,
5047 event: &str,
5048 ) -> Result<()> {
5049 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5050 let posted = self.gh(&[
5051 "api",
5052 "-X",
5053 "POST",
5054 &path,
5055 "-f",
5056 &format!("commit_id={commit}"),
5057 "-f",
5058 &format!("event={event}"),
5059 "-f",
5060 &format!("body={body}"),
5061 "--silent",
5062 ]);
5063 match posted {
5064 Ok(_) => Ok(()),
5065 Err(post_error) => reconcile_review_post(
5066 number,
5067 body,
5068 viewer,
5069 commit,
5070 post_error,
5071 self.try_pr_reviews(number),
5072 ),
5073 }
5074 }
5075
5076 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5077 let body = signed(&self.record_failed_write(self.clean(body))?);
5078 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5079 if has_exact_comment(&comments, &body) {
5080 return Ok(());
5081 }
5082 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5083 let result = match posted {
5084 Ok(_) => Ok(()),
5085 Err(post_error) => {
5086 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5087 }
5088 };
5089 self.record_write(result)
5090 }
5091
5092 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5097 self.comment_issue(number, body)?;
5098 let n = number.to_string();
5099 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5100 Ok(_) => Ok(()),
5101 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5103 spar_err!(
5104 "commented on #{number} but could not close it: {}",
5105 e.last_line()
5106 )
5107 }),
5108 };
5109 self.record_write(closed)
5110 }
5111
5112 pub fn edit_issue_body(
5127 &self,
5128 number: i64,
5129 expected: &str,
5130 body: &str,
5131 inserted: &str,
5132 ) -> Result<()> {
5133 let cleaned = self.record_failed_write(self.clean(inserted))?;
5134 if cleaned.trim() != inserted.trim() {
5135 return self.record_failed_write(Err(spar_err!(
5136 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5137 )));
5138 }
5139 let current = self.record_failed_write(self.issue_body(number))?;
5140 if current != expected {
5141 return self.record_failed_write(Err(spar_err!(
5142 "the body of #{number} changed since it was read, so it was left alone rather \
5143 than written over."
5144 )));
5145 }
5146 let edited = self.gh_stdin(
5147 &["issue", "edit", &number.to_string(), "--body-file", "-"],
5148 body,
5149 );
5150 let result = match edited {
5151 Ok(_) => Ok(()),
5152 Err(edit_error) => {
5153 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5154 }
5155 };
5156 self.record_write(result)
5157 }
5158
5159 pub fn issue_body(&self, number: i64) -> Result<String> {
5161 #[derive(Deserialize)]
5162 struct Row {
5163 #[serde(default)]
5164 body: Option<String>,
5165 }
5166 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5167 let row: Row = serde_json::from_str(text.trim())
5168 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5169 Ok(row.body.unwrap_or_default())
5170 }
5171
5172 pub fn open_issue_rows(&self) -> Vec<Issue> {
5177 let text = self.gh_try(&[
5178 "issue",
5179 "list",
5180 "--state",
5181 "open",
5182 "--limit",
5183 &FETCH_CEILING.to_string(),
5184 "--json",
5185 "number,title,body,labels,state,stateReason,url",
5186 ]);
5187 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5188 }
5189
5190 pub fn open_pr_rows(&self) -> Vec<PrRow> {
5192 let text = self.gh_try(&[
5193 "pr",
5194 "list",
5195 "--state",
5196 "open",
5197 "--limit",
5198 &FETCH_CEILING.to_string(),
5199 "--json",
5200 "number,title,changedFiles,additions,deletions",
5201 ]);
5202 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5203 }
5204
5205 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5206 self.create_issue_apart_from(title, body, None)
5207 }
5208
5209 pub fn create_issue_apart_from(
5210 &self,
5211 title: &str,
5212 body: &str,
5213 apart_from: Option<i64>,
5214 ) -> Result<String> {
5215 let title = self.record_failed_write(self.clean_title(title))?;
5216 let body = self.record_failed_write(self.clean_issue_body(body))?;
5217 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5218 let result = match created {
5219 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5220 created => {
5221 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5222 reconcile_issue_creation(&title, created, found)
5223 }
5224 };
5225 self.record_write(result)
5226 }
5227}
5228
5229#[derive(Debug, Clone)]
5231pub struct ExistingIssue {
5232 pub number: i64,
5233 pub url: String,
5234 pub title: String,
5235 pub body: String,
5236 pub open: bool,
5237}
5238
5239impl Repo {
5240 pub(crate) fn try_exact_issue_apart_from(
5241 &self,
5242 title: &str,
5243 body: &str,
5244 apart_from: Option<i64>,
5245 ) -> Result<Option<ExistingIssue>> {
5246 #[derive(Deserialize)]
5247 #[serde(rename_all = "camelCase")]
5248 struct Row {
5249 number: i64,
5250 #[serde(default)]
5251 title: String,
5252 #[serde(default)]
5253 url: String,
5254 #[serde(default)]
5255 body: Option<String>,
5256 #[serde(default)]
5257 state: String,
5258 }
5259
5260 let text = self.gh(&[
5261 "issue",
5262 "list",
5263 "--state",
5264 "all",
5265 "--limit",
5266 "100",
5267 "--json",
5268 "number,title,url,body,state,stateReason",
5269 ])?;
5270 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5271 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5272 Ok(rows
5273 .into_iter()
5274 .filter(|row| Some(row.number) != apart_from)
5275 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5276 .map(|row| ExistingIssue {
5277 number: row.number,
5278 url: row.url,
5279 title: row.title,
5280 body: row.body.unwrap_or_default(),
5281 open: row.state.eq_ignore_ascii_case("open"),
5282 }))
5283 }
5284
5285 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5293 self.find_similar_issue_apart_from(title, body, None)
5294 }
5295
5296 pub fn find_similar_issue_apart_from(
5304 &self,
5305 title: &str,
5306 body: &str,
5307 apart_from: Option<i64>,
5308 ) -> Option<ExistingIssue> {
5309 self.try_similar_issue(title, body, apart_from, true)
5310 .ok()
5311 .flatten()
5312 }
5313
5314 pub fn try_find_similar_issue_apart_from(
5316 &self,
5317 title: &str,
5318 body: &str,
5319 apart_from: Option<i64>,
5320 ) -> Result<Option<ExistingIssue>> {
5321 self.try_similar_issue(title, body, apart_from, false)
5322 }
5323
5324 fn try_similar_issue(
5326 &self,
5327 title: &str,
5328 body: &str,
5329 apart_from: Option<i64>,
5330 include_closed: bool,
5331 ) -> Result<Option<ExistingIssue>> {
5332 #[derive(Deserialize)]
5333 #[serde(rename_all = "camelCase")]
5334 struct Row {
5335 number: i64,
5336 #[serde(default)]
5337 title: String,
5338 #[serde(default)]
5339 url: String,
5340 #[serde(default)]
5341 body: String,
5342 #[serde(default)]
5343 state: String,
5344 }
5345 if title.trim().is_empty() {
5346 return Ok(None);
5347 }
5348 let query: String = title
5351 .chars()
5352 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5353 .take(120)
5354 .collect();
5355 let state = if include_closed { "all" } else { "open" };
5364 let text = self.gh(&[
5365 "issue",
5366 "list",
5367 "--state",
5368 state,
5369 "--limit",
5370 "100",
5371 "--search",
5372 query.trim(),
5373 "--json",
5374 "number,title,url,body,state,stateReason",
5375 ])?;
5376 let rows: Vec<Row> = serde_json::from_str(text.trim())
5377 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5378
5379 Ok(rows
5380 .into_iter()
5381 .filter(|row| Some(row.number) != apart_from)
5382 .find(|row| {
5383 row.title.trim().eq_ignore_ascii_case(title.trim())
5384 || textsim::covers(title, body, &row.title, &row.body)
5385 })
5386 .map(|row| ExistingIssue {
5387 number: row.number,
5388 url: row.url,
5389 title: row.title,
5390 open: row.state.eq_ignore_ascii_case("open"),
5391 body: row.body,
5392 }))
5393 }
5394
5395 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5397 #[derive(Deserialize)]
5398 struct Row {
5399 title: String,
5400 url: String,
5401 }
5402 let needle = title.trim().to_lowercase();
5403 if needle.is_empty() {
5404 return None;
5405 }
5406 let query: String = title
5408 .chars()
5409 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5410 .take(120)
5411 .collect();
5412 let text = self.gh_try(&[
5413 "issue",
5414 "list",
5415 "--state",
5416 "all",
5417 "--limit",
5418 "100",
5419 "--search",
5420 query.trim(),
5421 "--json",
5422 "number,title,url",
5423 ]);
5424 serde_json::from_str::<Vec<Row>>(text.trim())
5425 .ok()?
5426 .into_iter()
5427 .find(|row| row.title.trim().to_lowercase() == needle)
5428 .map(|row| row.url)
5429 }
5430
5431 pub fn mark_ready(&self, number: i64) -> bool {
5439 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5440 Ok(_) => true,
5441 Err(e) => {
5442 logdim!(
5443 "PR #{number} is approved but could not be taken out of draft: {}",
5444 e.last_line()
5445 );
5446 false
5447 }
5448 }
5449 }
5450
5451 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5453 let listed = self
5454 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5455 .ok()?;
5456 let wanted = format!("branch refs/heads/{branch}");
5457 let mut current: Option<PathBuf> = None;
5458 for line in listed.lines() {
5459 if let Some(path) = line.strip_prefix("worktree ") {
5460 current = Some(PathBuf::from(path));
5461 } else if line == wanted {
5462 return current;
5463 }
5464 }
5465 None
5466 }
5467
5468 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5484 let branch = self.pr_view(number).ok()?.head_ref_name;
5485 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5486 return None;
5487 }
5488 let mut released = None;
5489 if let Some(path) = self.worktree_holding(&branch) {
5490 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5491 return None;
5492 }
5493 if !matches!(
5494 self.has_recoverable_work(&path, Disposable::BuildOutput),
5495 Ok(false)
5496 ) {
5497 return None;
5498 }
5499 match self.remove_worktree_at(&path) {
5500 Ok(true) => released = Some(path),
5501 _ => return None,
5502 }
5503 }
5504 match self.delete_branch_if_safe(&branch) {
5505 Ok(true) => {
5506 self.forget_branch(&branch);
5507 Some(match released {
5508 Some(path) => format!(
5509 " The worktree at {} was released and branch {branch} deleted.",
5510 path.display()
5511 ),
5512 None => format!(" Branch {branch} was deleted."),
5513 })
5514 }
5515 _ => released.map(|path| {
5516 format!(
5517 " The worktree at {} was released, but branch {branch} was kept.",
5518 path.display()
5519 )
5520 }),
5521 }
5522 }
5523
5524 pub fn merge_pr(&self, number: i64) -> Result<()> {
5528 let n = number.to_string();
5529 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5530 Ok(_) => Ok(()),
5531 Err(e) => {
5532 if self.pr_state(number) == "MERGED" {
5533 let finished = self
5534 .finish_merged_branch_cleanup(number)
5535 .unwrap_or_default();
5536 logdim!(
5537 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5538 e.last_line()
5539 );
5540 Ok(())
5541 } else {
5542 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5543 }
5544 }
5545 };
5546 self.record_write(merged)
5547 }
5548
5549 pub fn merge_pr_at_head(
5551 &self,
5552 number: i64,
5553 expected_head: &str,
5554 delete_branch: bool,
5555 ) -> Result<()> {
5556 let n = number.to_string();
5557 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5558 Ok(_) => Ok(()),
5559 Err(e) => {
5560 if self.pr_state(number) == "MERGED" {
5561 let finished = self
5562 .finish_merged_branch_cleanup(number)
5563 .unwrap_or_default();
5564 logdim!(
5565 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5566 e.last_line()
5567 );
5568 Ok(())
5569 } else {
5570 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5571 }
5572 }
5573 };
5574 self.record_write(merged)
5575 }
5576
5577 pub fn followups_path(&self) -> PathBuf {
5582 self.root.join(STATE_DIR).join("followups.md")
5583 }
5584
5585 pub fn brainstorms_dir(&self) -> PathBuf {
5590 self.root.join(STATE_DIR).join("brainstorms")
5591 }
5592
5593 pub fn worked_followups_path(&self) -> PathBuf {
5601 self.root.join(STATE_DIR).join("followups.done.md")
5602 }
5603
5604 pub fn spend_path(&self) -> PathBuf {
5607 self.root.join(STATE_DIR).join("state").join("spend.json")
5608 }
5609
5610 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5616 if calls.is_empty() {
5617 return Ok(());
5618 }
5619 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5620 .ok()
5621 .and_then(|text| serde_json::from_str(&text).ok())
5622 .unwrap_or_default();
5623 all.extend(calls.iter().cloned());
5624 if let Some(parent) = self.spend_path().parent() {
5625 let _ = std::fs::create_dir_all(parent);
5626 }
5627 write_json_atomic(&self.spend_path(), &all)
5628 }
5629
5630 pub fn implementors_path(&self) -> PathBuf {
5636 self.root
5637 .join(STATE_DIR)
5638 .join("state")
5639 .join("implementors.json")
5640 }
5641
5642 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5643 std::fs::read_to_string(self.implementors_path())
5644 .ok()
5645 .and_then(|text| serde_json::from_str(&text).ok())
5646 .unwrap_or_default()
5647 }
5648
5649 pub fn remember_implementor(&self, issue: i64, agent: &str) {
5652 let mut known = self.read_implementors();
5653 known.insert(issue, agent.to_string());
5654 if let Some(parent) = self.implementors_path().parent() {
5655 let _ = std::fs::create_dir_all(parent);
5656 }
5657 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5658 logdim!("could not record who implemented #{issue}: {e}");
5659 }
5660 }
5661
5662 pub fn contested_path(&self) -> PathBuf {
5667 self.root
5668 .join(STATE_DIR)
5669 .join("state")
5670 .join("contested.json")
5671 }
5672
5673 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5675 std::fs::read_to_string(self.contested_path())
5676 .ok()
5677 .and_then(|text| serde_json::from_str(&text).ok())
5678 .unwrap_or_default()
5679 }
5680
5681 pub fn remember_contested(&self, items: &[ContestedItem]) {
5686 if items.is_empty() {
5687 return;
5688 }
5689 let mut known = self.read_contested();
5690 for item in items {
5691 known.insert(
5692 item.issue,
5693 Contested {
5694 title: item.title.clone(),
5695 positions: item.positions.clone(),
5696 reasons: item.reasons.clone(),
5697 },
5698 );
5699 }
5700 if let Some(parent) = self.contested_path().parent() {
5701 let _ = std::fs::create_dir_all(parent);
5702 }
5703 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5704 logdim!("could not record the contested issues: {e}");
5705 }
5706 }
5707
5708 pub fn forget_contested(&self, numbers: &[i64]) {
5710 let mut known = self.read_contested();
5711 let before = known.len();
5712 known.retain(|number, _| !numbers.contains(number));
5713 if known.len() == before {
5714 return;
5715 }
5716 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5717 logdim!("could not update the contested issues: {e}");
5718 }
5719 }
5720
5721 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5722 self.root
5723 .join(STATE_DIR)
5724 .join("state")
5725 .join(format!("checkin-{number}.json"))
5726 }
5727
5728 pub fn answer_state_path(&self, number: i64) -> PathBuf {
5730 self.root
5731 .join(STATE_DIR)
5732 .join("state")
5733 .join(format!("answer-{number}.json"))
5734 }
5735
5736 pub fn answer_notes_path(&self, number: i64) -> PathBuf {
5739 self.root
5740 .join(STATE_DIR)
5741 .join("answers")
5742 .join(format!("issue-{number}.md"))
5743 }
5744
5745 pub fn review_record_path(&self, number: i64) -> PathBuf {
5747 self.root
5748 .join(STATE_DIR)
5749 .join("state")
5750 .join(format!("review-{number}.json"))
5751 }
5752
5753 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5756 let path = self.review_record_path(number);
5757 let text = std::fs::read_to_string(&path).ok()?;
5758 match serde_json::from_str(&text) {
5759 Ok(record) => Some(record),
5760 Err(e) => {
5761 logdim!("could not read {}: {e}", path.display());
5762 None
5763 }
5764 }
5765 }
5766
5767 pub fn write_review_record(
5768 &self,
5769 number: i64,
5770 record: &crate::model::ReviewRecord,
5771 ) -> Result<()> {
5772 write_json_atomic(&self.review_record_path(number), record)
5773 }
5774
5775 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5790 let path = self.followups_path();
5791 let heading = format!("## {}", title.trim());
5792 for seen in [&path, &self.worked_followups_path()] {
5793 if let Ok(existing) = std::fs::read_to_string(seen) {
5794 if has_heading(&existing, title) {
5795 logdim!("follow-up already noted: {title}");
5796 return Followup::Covered(format!("note: {}", title.trim()));
5797 }
5798 }
5799 }
5800 if let Some(parent) = path.parent() {
5801 let _ = std::fs::create_dir_all(parent);
5802 }
5803 use std::io::Write;
5804 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5811 match std::fs::OpenOptions::new()
5812 .create(true)
5813 .append(true)
5814 .open(&path)
5815 {
5816 Ok(mut file) => match file.write_all(entry.as_bytes()) {
5817 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5818 Err(e) => {
5819 logdim!("could not write {}: {e}", path.display());
5820 Followup::Failed
5821 }
5822 },
5823 Err(e) => {
5824 logdim!("could not write {}: {e}", path.display());
5825 Followup::Failed
5826 }
5827 }
5828 }
5829
5830 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5835 let path = self.worked_followups_path();
5836 if let Some(parent) = path.parent() {
5837 let _ = std::fs::create_dir_all(parent);
5838 }
5839 use std::io::Write;
5840 let entry = format!(
5841 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5842 title.trim(),
5843 body.trim()
5844 );
5845 if let Ok(mut file) = std::fs::OpenOptions::new()
5846 .create(true)
5847 .append(true)
5848 .open(&path)
5849 {
5850 let _ = file.write_all(entry.as_bytes());
5851 }
5852 }
5853
5854 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5863 self.root
5864 .join(STATE_DIR)
5865 .join("reviews")
5866 .join(format!("pr-{number}.md"))
5867 }
5868
5869 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5875 let path = self.pending_comment_path(number);
5876 if let Some(parent) = path.parent() {
5877 std::fs::create_dir_all(parent)
5878 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5879 }
5880 std::fs::write(&path, text)
5881 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5882 Ok(path)
5883 }
5884
5885 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5886 std::fs::read_to_string(self.pending_comment_path(number)).ok()
5887 }
5888
5889 pub fn state_path(&self, number: i64) -> PathBuf {
5890 self.root
5891 .join(STATE_DIR)
5892 .join("state")
5893 .join(format!("pr-{number}.json"))
5894 }
5895
5896 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5897 let path = self.state_path(number);
5898 let text = std::fs::read_to_string(&path).ok()?;
5899 match serde_json::from_str(&text) {
5900 Ok(state) => Some(state),
5901 Err(_) => {
5902 logdim!("could not read {}, starting fresh", path.display());
5903 None
5904 }
5905 }
5906 }
5907
5908 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5909 if let Some(local) = self.read_local_state(pr.number) {
5910 return Some(local);
5911 }
5912 if self.state_store.writes_pr() {
5913 return self.read_pr_state(pr.number);
5914 }
5915 None
5916 }
5917
5918 pub(crate) fn read_state_for_head(
5919 &self,
5920 pr: &PrView,
5921 actual_head: &str,
5922 ) -> Option<PersistedState> {
5923 let local = self
5924 .state_store
5925 .writes_local()
5926 .then(|| self.read_local_state(pr.number))
5927 .flatten();
5928 let remote = self
5929 .state_store
5930 .writes_pr()
5931 .then(|| self.read_pr_state(pr.number))
5932 .flatten();
5933 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5934 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5935 self.remember_checkpoint(pr.number, checkpoint);
5936 }
5937 choose_state_for_head(candidates, actual_head)
5938 }
5939
5940 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5941 self.try_read_pr_state(number).ok().flatten()
5942 }
5943
5944 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5945 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5946 if let Some(state) = parse_state_comment(&body) {
5947 return Ok(Some(state));
5948 }
5949 }
5950 Ok(None)
5951 }
5952
5953 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5954 let remote_state = if self.state_store.writes_pr() {
5955 self.try_read_pr_state(number)
5956 } else {
5957 Ok(None)
5958 };
5959 self.write_state_after_remote_read(number, state, remote_state)
5960 }
5961
5962 fn write_state_after_remote_read(
5963 &self,
5964 number: i64,
5965 state: &PersistedState,
5966 remote_state: Result<Option<PersistedState>>,
5967 ) -> Result<()> {
5968 let remote_checkpoint = if self.state_store.writes_pr() {
5969 self.record_failed_write(remote_state)?
5970 .map(|saved| saved.checkpoint)
5971 .unwrap_or_default()
5972 } else {
5973 0
5974 };
5975 let local_checkpoint = self
5976 .state_store
5977 .writes_local()
5978 .then(|| self.read_local_state(number))
5979 .flatten()
5980 .map(|saved| saved.checkpoint)
5981 .unwrap_or_default();
5982 let mut stamped = state.clone();
5983 stamped.checkpoint = state
5984 .checkpoint
5985 .max(local_checkpoint)
5986 .max(remote_checkpoint)
5987 .max(self.remembered_checkpoint(number))
5988 .saturating_add(1);
5989 self.remember_checkpoint(number, stamped.checkpoint);
5990 if self.state_store.writes_local() {
5991 write_json_atomic(&self.state_path(number), &stamped)?;
5992 }
5993 if self.state_store.writes_pr() {
5994 self.write_pr_state(number, &stamped)?;
5995 }
5996 Ok(())
5997 }
5998
5999 fn remembered_checkpoint(&self, number: i64) -> u64 {
6000 self.checkpoints
6001 .lock()
6002 .unwrap_or_else(std::sync::PoisonError::into_inner)
6003 .get(&number)
6004 .copied()
6005 .unwrap_or_default()
6006 }
6007
6008 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6009 let mut checkpoints = self
6010 .checkpoints
6011 .lock()
6012 .unwrap_or_else(std::sync::PoisonError::into_inner);
6013 let saved = checkpoints.entry(number).or_default();
6014 *saved = (*saved).max(checkpoint);
6015 }
6016
6017 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6018 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6022 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6023 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6024 if let Some(id) = comment_id {
6025 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6026 let field = format!("body={body}");
6027 let written = self
6028 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6029 .map(|_| ());
6030 return self.record_write(written);
6031 }
6032 let written = self
6033 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6034 .map(|_| ());
6035 self.record_write(written)
6036 }
6037
6038 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6046 self.try_issue_comments(number).unwrap_or_default()
6047 }
6048
6049 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6050 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6051 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6052 }
6053
6054 pub fn issue_row(&self, number: i64) -> Result<Value> {
6058 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6059 let text = self
6060 .gh(&["api", &path])
6061 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6062 serde_json::from_str(&text)
6063 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6064 }
6065
6066 pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6069 #[derive(Deserialize)]
6070 struct Row {
6071 number: i64,
6072 }
6073 let name = self
6075 .repo_name()
6076 .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6077 let text = self.gh(&[
6078 "search",
6079 "issues",
6080 "--repo",
6081 &name,
6082 "--mentions",
6083 login,
6084 "--state",
6085 "open",
6086 "--limit",
6087 &FETCH_CEILING.to_string(),
6088 "--json",
6089 "number",
6090 ])?;
6091 let rows: Vec<Row> = serde_json::from_str(text.trim())
6092 .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6093 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6094 numbers.sort_unstable();
6095 numbers.truncate(limit);
6096 Ok(numbers)
6097 }
6098
6099 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6106 let viewer = self.viewer_login()?.to_string();
6107 let mut mine = Vec::new();
6108 let mut theirs = Vec::new();
6109 for comment in self.try_issue_comments(number)? {
6110 let Some(body) = comment.get("body").and_then(Value::as_str) else {
6111 continue;
6112 };
6113 if !body.contains("spar:state") {
6114 continue;
6115 }
6116 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6117 continue;
6118 };
6119 let author = comment
6120 .get("user")
6121 .and_then(|u| u.get("login"))
6122 .and_then(Value::as_str)
6123 .unwrap_or_default();
6124 if is_state_comment(body, author, &viewer) {
6125 mine.push((id, body.to_string()));
6126 } else {
6127 theirs.push(format!("@{author}"));
6128 }
6129 }
6130 if !theirs.is_empty() {
6131 logdim!(
6132 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6133 own state block is patched or deleted.",
6134 theirs.len(),
6135 theirs.join(", ")
6136 );
6137 }
6138 Ok(mine)
6139 }
6140
6141 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6142 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6143 }
6144
6145 pub fn clear_state(&self, number: i64) {
6147 let path = self.state_path(number);
6148 let _ = std::fs::remove_file(&path);
6149 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6150 }
6151
6152 pub fn prune_state(&self) -> Vec<String> {
6159 let base = self.root.join(STATE_DIR).join("state");
6160 let Ok(entries) = std::fs::read_dir(&base) else {
6161 return Vec::new();
6162 };
6163 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6164 for name in entries
6165 .flatten()
6166 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6167 {
6168 if let Some(number) = state_file_number(&name) {
6169 by_number.entry(number).or_default().push(name);
6170 }
6171 }
6172
6173 let mut removed = Vec::new();
6174 for (number, mut names) in by_number {
6175 if !is_finished(&self.pr_state(number)) {
6176 continue;
6177 }
6178 names.sort();
6179 for name in names {
6180 let _ = std::fs::remove_file(base.join(&name));
6181 removed.push(format!("state {name}"));
6182 }
6183 }
6184 removed
6185 }
6186
6187 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6191 #[derive(Deserialize)]
6192 struct Row {
6193 number: i64,
6194 }
6195 let numbers = match numbers {
6196 Some(numbers) => numbers,
6197 None => {
6198 let listed: Result<Vec<i64>> = (|| {
6199 let text = self.gh(&[
6200 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6201 ])?;
6202 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6203 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6204 Ok(rows.into_iter().map(|row| row.number).collect())
6205 })();
6206 match self.record_failed_write(listed) {
6207 Ok(numbers) => numbers,
6208 Err(e) => {
6209 logdim!("could not inspect pull requests for state cleanup: {e}");
6210 return Vec::new();
6211 }
6212 }
6213 }
6214 };
6215
6216 let mut removed = Vec::new();
6217 for number in numbers {
6218 let state = match self.record_failed_write(self.try_pr_state(number)) {
6219 Ok(state) => state,
6220 Err(e) => {
6221 logdim!("could not inspect PR #{number} for state cleanup: {e}");
6222 continue;
6223 }
6224 };
6225 if !is_finished(&state) {
6226 continue;
6227 }
6228 let comments = match self.record_failed_write(self.try_state_comments(number)) {
6229 Ok(comments) => comments,
6230 Err(e) => {
6231 logdim!("could not inspect state comments on PR #{number}: {e}");
6232 continue;
6233 }
6234 };
6235 for (id, _) in comments {
6236 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6237 let deleted = self
6238 .gh(&["api", "-X", "DELETE", &path, "--silent"])
6239 .map(|_| ());
6240 match self.record_write(deleted) {
6241 Ok(()) => removed.push(format!("state comment on PR #{number}")),
6242 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6243 }
6244 }
6245 }
6246 removed
6247 }
6248
6249 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6256 let base = self.root.join(WORKTREE_DIR);
6257 let mut removed = Vec::new();
6258 let known = self.known_branches();
6259
6260 if let Ok(entries) = std::fs::read_dir(&base) {
6261 let mut names: Vec<String> = entries
6262 .flatten()
6263 .filter(|e| e.path().is_dir())
6264 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6265 .collect();
6266 names.sort();
6267
6268 for name in names {
6269 if let Some(rest) = name.strip_prefix("review-") {
6272 let number: i64 = rest.parse().unwrap_or(-1);
6273 if !(force_all || is_finished(&self.pr_state(number))) {
6274 continue;
6275 }
6276 let path = base.join(&name);
6277 if force_all {
6278 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6279 if !belongs {
6280 return Ok(false);
6281 }
6282 let local_ref = review_ref(number);
6283 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6284 return Ok(false);
6285 }
6286 let head = self.head_oid_checked(&path)?;
6287 let recorded = self
6288 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6289 .trim()
6290 .to_string();
6291 Ok(head == recorded)
6292 });
6293 match owned {
6294 Ok(true) => {}
6295 Ok(false) => {
6296 logdim!(
6297 "kept {} because no matching SPAR review reference proves \
6298 ownership",
6299 path.display()
6300 );
6301 continue;
6302 }
6303 Err(e) => {
6304 logdim!(
6305 "kept {} because review ownership could not be verified: {}",
6306 path.display(),
6307 e.last_line()
6308 );
6309 continue;
6310 }
6311 }
6312 } else {
6313 if let Err(e) = self.refuse_review_worktree_changes(number) {
6314 logdim!(
6315 "kept {} because its review state could not be verified as \
6316 disposable: {}",
6317 path.display(),
6318 e.last_line()
6319 );
6320 continue;
6321 }
6322 }
6323 if force_all {
6324 if self.remove_worktree_at_force(&path) {
6325 self.git_try(&["update-ref", "-d", &review_ref(number)]);
6326 }
6327 } else {
6328 self.release_review_worktree(number);
6329 }
6330 if !path.exists() {
6331 removed.push(name);
6332 }
6333 continue;
6334 }
6335 let branch = format!("{}{name}", self.branch_prefix);
6336 if !(force_all || self.worktree_is_done(&branch)) {
6337 continue;
6338 }
6339 if !known.contains_key(&branch) {
6340 logdim!("kept {branch} because it has no branch record");
6341 continue;
6342 }
6343 let path = base.join(&name);
6344 if !force_all {
6345 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6346 Ok(true) => {
6347 logdim!(
6348 "kept {} because it contains uncommitted changes or ignored files",
6349 path.display()
6350 );
6351 continue;
6352 }
6353 Err(e) => {
6354 logdim!(
6355 "kept {} because its Git state could not be checked: {}",
6356 path.display(),
6357 e.last_line()
6358 );
6359 continue;
6360 }
6361 Ok(false) => {}
6362 }
6363 match self.branch_deletion_is_safe(&branch) {
6364 Ok(true) => {}
6365 Ok(false) => {
6366 logdim!(
6367 "kept {branch} because no surviving ref preserves its tip or \
6368 reflog-only commits"
6369 );
6370 continue;
6371 }
6372 Err(e) => {
6373 logdim!(
6374 "kept {branch} because preservation could not be verified: {}",
6375 e.last_line()
6376 );
6377 continue;
6378 }
6379 }
6380 }
6381 let removed_worktree = if force_all {
6382 self.remove_worktree_at_force(&path)
6383 } else {
6384 match self.remove_worktree_at(&path) {
6385 Ok(removed) => removed,
6386 Err(error) => {
6387 logdim!(
6388 "kept {branch} and {} because removal did not reach a confirmed \
6389 quiet point: {}",
6390 path.display(),
6391 error.last_line()
6392 );
6393 false
6394 }
6395 }
6396 };
6397 if !removed_worktree {
6398 continue;
6399 }
6400 if force_all {
6401 self.git_try(&["branch", "-D", &branch]);
6402 self.forget_branch(&branch);
6403 } else {
6404 match self.delete_branch_if_safe(&branch) {
6405 Ok(true) => self.forget_branch(&branch),
6406 Ok(false) => logdim!(
6407 "kept {branch} because its tip or reflog changed before deletion"
6408 ),
6409 Err(error) => logdim!(
6410 "kept {branch} because deletion safety could not be rechecked: {}",
6411 error.last_line()
6412 ),
6413 }
6414 }
6415 removed.push(name);
6416 }
6417 }
6418 removed.extend(self.prune_branches(force_all));
6419 removed
6420 }
6421
6422 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6429 let known = self.known_branches();
6430 let branches: Vec<String> = known.keys().cloned().collect();
6431 if branches.is_empty() {
6432 return Vec::new();
6433 }
6434
6435 let checked_out: Vec<String> = self
6436 .git_try(&["worktree", "list", "--porcelain"])
6437 .lines()
6438 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6439 .collect();
6440
6441 let existing: Vec<String> = self
6444 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6445 .lines()
6446 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6447 .collect();
6448
6449 let mut removed = Vec::new();
6450 for branch in branches {
6451 if !existing.contains(&branch) {
6452 self.forget_branch(&branch); continue;
6454 }
6455 if checked_out.contains(&branch) {
6456 continue;
6457 }
6458 if !(force_all || self.worktree_is_done(&branch)) {
6459 continue;
6460 }
6461 if !force_all {
6462 let Some(_record) = known.get(&branch) else {
6463 continue;
6464 };
6465 match self.branch_deletion_is_safe(&branch) {
6466 Ok(true) => {}
6467 Ok(false) => {
6468 logdim!(
6469 "kept {branch} because no surviving ref preserves its tip or \
6470 reflog-only commits"
6471 );
6472 continue;
6473 }
6474 Err(e) => {
6475 logdim!(
6476 "kept {branch} because preservation could not be verified: {}",
6477 e.last_line()
6478 );
6479 continue;
6480 }
6481 }
6482 }
6483 let deleted = if force_all {
6484 self.git(&["branch", "-D", &branch]).map(|_| true)
6485 } else {
6486 self.delete_branch_if_safe(&branch)
6487 };
6488 match deleted {
6489 Ok(true) => {
6490 self.forget_branch(&branch);
6491 removed.push(format!("branch {branch}"));
6492 }
6493 Ok(false) => {
6494 logdim!("kept {branch} because its tip or reflog changed before deletion");
6495 }
6496 Err(e) => {
6497 logdim!("could not delete {branch}: {}", e.last_line());
6500 }
6501 }
6502 }
6503 removed
6504 }
6505
6506 fn worktree_is_done(&self, branch: &str) -> bool {
6508 #[derive(Deserialize)]
6509 struct Row {
6510 state: String,
6511 }
6512 let entry = branch
6513 .strip_prefix(self.branch_prefix.as_str())
6514 .unwrap_or(branch);
6515 if let Some(rest) = entry.strip_prefix("pr-") {
6516 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6517 }
6518 if entry.starts_with("issue-") || entry.starts_with("split-") {
6522 let text = self.gh_try(&[
6523 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6524 ]);
6525 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6526 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6527 }
6528 false
6529 }
6530}
6531
6532pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6544 match probe() {
6545 Ok(value) => Ok(value),
6546 Err(first) => {
6547 logdim!("retrying a worktree probe after: {}", first.last_line());
6548 probe()
6549 }
6550 }
6551}
6552
6553pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6559 let root = std::fs::canonicalize(cwd)
6560 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6561 let mut files = BTreeMap::new();
6562 let mut visited = BTreeSet::new();
6563 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6564 Ok(AttributeState { files })
6565}
6566
6567fn collect_attribute_files(
6568 root: &Path,
6569 repository: &Path,
6570 prefix: &Path,
6571 visited: &mut BTreeSet<PathBuf>,
6572 files: &mut BTreeMap<PathBuf, [u8; 32]>,
6573) -> Result<()> {
6574 let canonical = std::fs::canonicalize(repository)
6575 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6576 if !visited.insert(canonical) {
6577 bail!("submodule recursion revisited {}", repository.display());
6578 }
6579 let entries = index_entries(repository)?;
6580 let mut paths: BTreeSet<PathBuf> = entries
6581 .iter()
6582 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6583 .map(|entry| entry.path.clone())
6584 .collect();
6585 paths.extend(untracked_attribute_paths(repository)?);
6591 for path in paths {
6592 let from_root = prefix.join(&path);
6593 let state = attribute_file_fingerprint(&root.join(&from_root))?;
6594 files.insert(from_root, state);
6595 }
6596 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6597 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6598 continue;
6599 };
6600 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6601 }
6602 Ok(())
6603}
6604
6605pub(crate) fn uncertain_worktree_change(
6608 cwd: &Path,
6609 message: impl Into<String>,
6610) -> crate::error::SparError {
6611 let message = message.into();
6612 let marker = write_recovery_marker(cwd, &message);
6613 let note = match marker {
6614 Ok(path) => format!(" Recovery marker: {}.", path.display()),
6615 Err(e) => format!(
6616 " A recovery marker could not be written: {}.",
6617 e.last_line()
6618 ),
6619 };
6620 crate::error::SparError::uncertain_write(format!("{message}{note}"))
6621}
6622
6623fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6624 use std::sync::atomic::{AtomicU32, Ordering};
6625 static NEXT: AtomicU32 = AtomicU32::new(0);
6626 for _ in 0..1000 {
6627 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6628 let path = cwd.join(format!(
6629 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6630 std::process::id()
6631 ));
6632 let mut options = OpenOptions::new();
6633 options.write(true).create_new(true);
6634 #[cfg(unix)]
6635 {
6636 use std::os::unix::fs::OpenOptionsExt;
6637 options.mode(0o600);
6638 }
6639 match options.open(&path) {
6640 Ok(mut file) => {
6641 file.write_all(detail.as_bytes())
6642 .and_then(|_| file.write_all(b"\n"))
6643 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6644 return Ok(path);
6645 }
6646 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6647 Err(e) => {
6648 return Err(spar_err!(
6649 "could not create a recovery marker in {}: {e}",
6650 cwd.display()
6651 ))
6652 }
6653 }
6654 }
6655 bail!(
6656 "could not choose a free recovery marker name in {}",
6657 cwd.display()
6658 )
6659}
6660
6661fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6666 let mut argv = vec![
6667 "git".to_string(),
6668 "-c".to_string(),
6669 "maintenance.auto=false".to_string(),
6670 "-c".to_string(),
6671 "gc.auto=0".to_string(),
6672 ];
6673 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6674 argv
6675}
6676
6677fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6678 let mut argv = git_without_maintenance_argv(&[]);
6679 argv.extend([
6680 "-c".to_string(),
6681 "core.fsmonitor=".to_string(),
6682 "-c".to_string(),
6683 "commit.gpgsign=false".to_string(),
6684 "-c".to_string(),
6685 "core.hooksPath=/dev/null".to_string(),
6686 ]);
6687 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6688 argv
6689}
6690
6691pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6698 let root = std::fs::canonicalize(cwd)
6699 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6700 let mut files = BTreeMap::new();
6701 let mut ignored = BTreeSet::new();
6702 let mut visited = BTreeSet::new();
6703 collect_untracked_files(
6704 &root,
6705 &root,
6706 Path::new(""),
6707 &mut visited,
6708 &mut files,
6709 &mut ignored,
6710 )?;
6711 Ok(IgnoredState { files, ignored })
6712}
6713
6714fn collect_untracked_files(
6715 root: &Path,
6716 repository: &Path,
6717 prefix: &Path,
6718 visited: &mut BTreeSet<PathBuf>,
6719 files: &mut BTreeMap<PathBuf, UntrackedFile>,
6720 ignored: &mut BTreeSet<PathBuf>,
6721) -> Result<()> {
6722 let canonical = std::fs::canonicalize(repository)
6723 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6724 if !visited.insert(canonical.clone()) {
6725 bail!("submodule recursion revisited {}", canonical.display());
6726 }
6727 let listed = run_git_bytes(
6731 repository,
6732 &["ls-files", "--others", "--exclude-standard", "-z"],
6733 )?;
6734 if !listed.is_empty() && !listed.ends_with(&[0]) {
6735 bail!(
6736 "git returned an unterminated untracked-file list for {}",
6737 repository.display()
6738 );
6739 }
6740
6741 for raw in listed
6742 .split(|byte| *byte == 0)
6743 .filter(|raw| !raw.is_empty())
6744 {
6745 let (relative, nested) = untracked_record(raw, "untracked")?;
6746 let from_root = prefix.join(&relative);
6747 if is_spar_own_path(&from_root) {
6748 continue;
6749 }
6750 let absolute = root.join(&from_root);
6751 let fingerprint = if nested {
6752 nested_repository_fingerprint(&absolute)?
6753 } else {
6754 ignored_file_fingerprint(&absolute)?
6755 };
6756 if files.insert(from_root.clone(), fingerprint).is_some() {
6757 bail!(
6758 "git returned the untracked path more than once: {:?}",
6759 from_root
6760 );
6761 }
6762 }
6763
6764 let ignored_listed = run_git_bytes(
6765 repository,
6766 &[
6767 "ls-files",
6768 "--others",
6769 "--ignored",
6770 "--exclude-standard",
6771 "-z",
6772 ],
6773 )?;
6774 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6775 bail!(
6776 "git returned an unterminated ignored-file list for {}",
6777 repository.display()
6778 );
6779 }
6780 for raw in ignored_listed
6786 .split(|byte| *byte == 0)
6787 .filter(|raw| !raw.is_empty())
6788 {
6789 let (relative, nested) = untracked_record(raw, "ignored")?;
6790 let from_root = prefix.join(relative);
6791 if is_spar_own_path(&from_root) {
6792 continue;
6793 }
6794 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
6795 let absolute = root.join(&from_root);
6796 let fingerprint = if nested {
6797 nested_repository_fingerprint(&absolute)?
6798 } else {
6799 ignored_file_fingerprint(&absolute)?
6800 };
6801 slot.insert(fingerprint);
6802 }
6803 if !ignored.insert(from_root.clone()) {
6804 bail!(
6805 "git returned the ignored path more than once: {:?}",
6806 from_root
6807 );
6808 }
6809 }
6810
6811 for link in gitlinks(repository)? {
6812 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6813 continue;
6814 };
6815 collect_untracked_files(
6816 root,
6817 &submodule,
6818 &prefix.join(&link.path),
6819 visited,
6820 files,
6821 ignored,
6822 )?;
6823 }
6824 Ok(())
6825}
6826
6827fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
6828 let argv = git_without_automation_argv(args);
6829 proc::run_bytes(
6830 &argv,
6831 &ExecOpts::new()
6832 .cwd(cwd)
6833 .timeout_secs(30)
6834 .stop_descendants(true),
6835 )
6836}
6837
6838fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
6839 let argv = git_without_automation_argv(args);
6840 proc::run(
6841 &argv,
6842 &ExecOpts::new()
6843 .cwd(cwd)
6844 .timeout_secs(30)
6845 .stop_descendants(true),
6846 )
6847}
6848
6849fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
6850 let path = path.to_str().ok_or_else(|| {
6851 spar_err!(
6852 "cannot verify filtered content for a non-UTF-8 path in {}",
6853 cwd.display()
6854 )
6855 })?;
6856 let path_arg = format!("--path={path}");
6857 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
6858 Ok(Sha256::digest(bytes).into())
6859}
6860
6861fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
6862 let relative = path_from_git_bytes(raw)?;
6863 if relative.is_absolute()
6864 || relative.components().any(|component| {
6865 matches!(
6866 component,
6867 std::path::Component::ParentDir
6868 | std::path::Component::RootDir
6869 | std::path::Component::Prefix(_)
6870 )
6871 })
6872 {
6873 bail!("git returned an unsafe {kind} path: {:?}", relative);
6874 }
6875 Ok(relative)
6876}
6877
6878fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6887 let nested = raw.last() == Some(&b'/');
6888 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6889 if trimmed.is_empty() {
6890 bail!("git returned an empty {kind} path");
6891 }
6892 Ok((safe_git_path(trimmed, kind)?, nested))
6893}
6894
6895fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6896 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6897 if !listed.is_empty() && !listed.ends_with(&[0]) {
6898 bail!(
6899 "git returned an unterminated index listing for {}",
6900 cwd.display()
6901 );
6902 }
6903 let mut entries = Vec::new();
6904 for record in listed
6905 .split(|byte| *byte == 0)
6906 .filter(|record| !record.is_empty())
6907 {
6908 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6909 bail!(
6910 "git returned a malformed index record for {}",
6911 cwd.display()
6912 );
6913 };
6914 let header = &record[..tab];
6915 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6916 if fields.len() != 3 {
6917 bail!(
6918 "git returned a malformed index header for {}",
6919 cwd.display()
6920 );
6921 }
6922 if fields[2] != b"0" {
6923 continue;
6924 }
6925 let mode = std::str::from_utf8(fields[0])
6926 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6927 .to_string();
6928 let oid = std::str::from_utf8(fields[1])
6929 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6930 .to_string();
6931 entries.push(IndexEntry {
6932 path: safe_git_path(&record[tab + 1..], "index")?,
6933 mode,
6934 oid,
6935 });
6936 }
6937 Ok(entries)
6938}
6939
6940fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6942 let listed = run_git_bytes(
6943 cwd,
6944 &[
6945 "ls-files",
6946 "--others",
6947 "--exclude-standard",
6948 "-z",
6949 "--",
6950 ".gitattributes",
6951 ":(glob)**/.gitattributes",
6952 ],
6953 )?;
6954 if !listed.is_empty() && !listed.ends_with(&[0]) {
6955 bail!(
6956 "git returned an unterminated attribute-file listing for {}",
6957 cwd.display()
6958 );
6959 }
6960 listed
6961 .split(|byte| *byte == 0)
6962 .filter(|record| !record.is_empty())
6963 .map(|raw| safe_git_path(raw, "attribute"))
6964 .collect()
6965}
6966
6967fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6968 if !untracked_attribute_paths(cwd)?.is_empty() {
6969 return Ok(true);
6970 }
6971
6972 let index = index_entries(cwd)?
6973 .into_iter()
6974 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6975 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6976 .collect::<BTreeMap<_, _>>();
6977 let head = tree_entries(cwd, "HEAD")?
6978 .into_iter()
6979 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6980 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6981 .collect::<BTreeMap<_, _>>();
6982 if index != head {
6983 return Ok(true);
6984 }
6985
6986 let effective = check_attributes(cwd, index.keys().cloned())?;
6987 let config = CheckoutConfig::read(cwd)?;
6988 for (path, (_mode, oid)) in index {
6989 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6990 return Ok(true);
6991 };
6992 let attributes = effective
6993 .get(&path)
6994 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6995 if allows_expected_crlf(&config, attributes)? {
6996 if worktree.mode == "120000" {
6997 return Ok(true);
6998 }
6999 let (normalized, every_lf_was_crlf) =
7000 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7001 if !every_lf_was_crlf || normalized != oid {
7002 return Ok(true);
7003 }
7004 } else if worktree.raw_oid != oid {
7005 return Ok(true);
7006 }
7007 }
7008 Ok(false)
7009}
7010
7011fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7012 Ok(index_entries(cwd)?
7013 .into_iter()
7014 .filter(|entry| entry.mode == "160000")
7015 .map(|entry| Gitlink {
7016 path: entry.path,
7017 oid: entry.oid,
7018 })
7019 .collect())
7020}
7021
7022fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7023 let mut tracked = BTreeMap::new();
7024 for entry in index_entries(cwd)? {
7025 if entry.mode == "160000" {
7026 continue;
7027 }
7028 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7029 tracked.insert(
7030 entry.path,
7031 TrackedEntry {
7032 index_mode: entry.mode,
7033 index_oid: entry.oid,
7034 worktree,
7035 },
7036 );
7037 }
7038 Ok(tracked)
7039}
7040
7041fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7042 let metadata = match std::fs::symlink_metadata(path) {
7043 Ok(metadata) => metadata,
7044 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7045 Err(e) => {
7046 return Err(spar_err!(
7047 "could not inspect tracked file {}: {e}",
7048 path.display()
7049 ))
7050 }
7051 };
7052 let mut fingerprint = Sha256::new();
7053 if metadata.file_type().is_symlink() {
7054 let target = std::fs::read_link(path)
7055 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7056 let bytes = os_str_bytes(target.as_os_str())?;
7057 fingerprint.update(b"symlink\0");
7058 fingerprint.update(&bytes);
7059 let content = Sha256::digest(&bytes).into();
7060 return Ok(Some(WorktreeFile {
7061 mode: "120000".to_string(),
7062 #[cfg(unix)]
7063 permissions: 0,
7064 raw_oid: git_blob_oid(oid_len, &bytes)?,
7065 fingerprint: fingerprint.finalize().into(),
7066 content,
7067 }));
7068 }
7069 if !metadata.is_file() {
7070 bail!("tracked path {} is not a file or symlink", path.display());
7071 }
7072
7073 let mut options = OpenOptions::new();
7074 options.read(true);
7075 #[cfg(unix)]
7076 {
7077 use std::os::unix::fs::OpenOptionsExt;
7078 options.custom_flags(libc::O_NOFOLLOW);
7079 }
7080 let mut file = options
7081 .open(path)
7082 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7083 let before = file
7084 .metadata()
7085 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7086 let mode = tracked_file_mode(&before);
7087 #[cfg(unix)]
7088 let permissions = {
7089 use std::os::unix::fs::MetadataExt;
7090 before.mode() & 0o7777
7091 };
7092 fingerprint.update(b"file\0");
7093 fingerprint.update(mode.as_bytes());
7094 #[cfg(unix)]
7095 fingerprint.update(permissions.to_le_bytes());
7096 fingerprint.update(before.len().to_le_bytes());
7097 let mut content = Sha256::new();
7098 let header = format!("blob {}\0", before.len());
7099 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7100 let mut buf = [0u8; 64 * 1024];
7101 loop {
7102 let read = file
7103 .read(&mut buf)
7104 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7105 if read == 0 {
7106 break;
7107 }
7108 fingerprint.update(&buf[..read]);
7109 content.update(&buf[..read]);
7110 object.update(&buf[..read]);
7111 }
7112 let after = file
7113 .metadata()
7114 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7115 if before.len() != after.len()
7116 || before.modified().ok() != after.modified().ok()
7117 || before.permissions() != after.permissions()
7118 {
7119 bail!(
7120 "tracked file {} changed while it was being inspected",
7121 path.display()
7122 );
7123 }
7124 let current = std::fs::symlink_metadata(path)
7125 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7126 if !same_file(&after, ¤t) {
7127 bail!(
7128 "tracked file {} was replaced while it was being inspected",
7129 path.display()
7130 );
7131 }
7132 Ok(Some(WorktreeFile {
7133 mode,
7134 #[cfg(unix)]
7135 permissions,
7136 raw_oid: object.finish(),
7137 fingerprint: fingerprint.finalize().into(),
7138 content: content.finalize().into(),
7139 }))
7140}
7141
7142fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7143 let metadata = std::fs::symlink_metadata(path)
7144 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7145 let mut digest = Sha256::new();
7146 if metadata.file_type().is_symlink() {
7147 digest.update(b"symlink\0");
7148 let target = std::fs::read_link(path)
7149 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7150 digest.update(os_str_bytes(target.as_os_str())?);
7151 return Ok(digest.finalize().into());
7152 }
7153 if !metadata.is_file() {
7154 bail!("attribute path {} is not a file or symlink", path.display());
7155 }
7156 let mut options = OpenOptions::new();
7157 options.read(true);
7158 #[cfg(unix)]
7159 {
7160 use std::os::unix::fs::OpenOptionsExt;
7161 options.custom_flags(libc::O_NOFOLLOW);
7162 }
7163 let mut file = options
7164 .open(path)
7165 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7166 let before = file
7167 .metadata()
7168 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7169 digest.update(b"file\0");
7170 let mut buf = [0u8; 64 * 1024];
7171 loop {
7172 let read = file
7173 .read(&mut buf)
7174 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7175 if read == 0 {
7176 break;
7177 }
7178 digest.update(&buf[..read]);
7179 }
7180 let after = file
7181 .metadata()
7182 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7183 let current = std::fs::symlink_metadata(path)
7184 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7185 if before.len() != after.len()
7186 || before.modified().ok() != after.modified().ok()
7187 || !same_file(&after, ¤t)
7188 {
7189 bail!(
7190 "attribute file {} changed while it was being inspected",
7191 path.display()
7192 );
7193 }
7194 Ok(digest.finalize().into())
7195}
7196
7197enum ObjectHasher {
7198 Sha1(Sha1),
7199 Sha256(Sha256),
7200}
7201
7202impl ObjectHasher {
7203 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7204 let mut hasher = match oid_len {
7205 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7206 64 => Self::Sha256(Sha256::new()),
7207 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7208 };
7209 hasher.update(header);
7210 Ok(hasher)
7211 }
7212
7213 fn update(&mut self, bytes: &[u8]) {
7214 match self {
7215 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7216 Self::Sha256(hasher) => hasher.update(bytes),
7217 }
7218 }
7219
7220 fn finish(self) -> String {
7221 let bytes = match self {
7222 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7223 Self::Sha256(hasher) => hasher.finalize().to_vec(),
7224 };
7225 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7226 }
7227}
7228
7229fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7230 let header = format!("blob {}\0", bytes.len());
7231 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7232 hasher.update(bytes);
7233 Ok(hasher.finish())
7234}
7235
7236fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7237 let mut first = open_regular_file(path)?;
7238 let first_before = first
7239 .metadata()
7240 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7241 let mut raw_len = 0u64;
7242 let mut crlf_pairs = 0u64;
7243 let mut previous_was_cr = false;
7244 let mut every_lf_was_crlf = true;
7245 let mut buf = [0u8; 64 * 1024];
7246 loop {
7247 let read = first
7248 .read(&mut buf)
7249 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7250 if read == 0 {
7251 break;
7252 }
7253 raw_len = raw_len
7254 .checked_add(read as u64)
7255 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7256 for byte in &buf[..read] {
7257 if *byte == b'\n' {
7258 if previous_was_cr {
7259 crlf_pairs += 1;
7260 } else {
7261 every_lf_was_crlf = false;
7262 }
7263 }
7264 previous_was_cr = *byte == b'\r';
7265 }
7266 }
7267 let first_after = first
7268 .metadata()
7269 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7270 let current = std::fs::symlink_metadata(path)
7271 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7272 if raw_len != first_before.len()
7273 || !stable_file_metadata(&first_before, &first_after)
7274 || !stable_file_metadata(&first_after, ¤t)
7275 {
7276 bail!(
7277 "tracked file {} changed while line endings were inspected",
7278 path.display()
7279 );
7280 }
7281
7282 let normalized_len = raw_len
7283 .checked_sub(crlf_pairs)
7284 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7285 let header = format!("blob {normalized_len}\0");
7286 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7287 let mut second = open_regular_file(path)?;
7288 let second_before = second
7289 .metadata()
7290 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7291 if !stable_file_metadata(&first_after, &second_before) {
7292 bail!(
7293 "tracked file {} changed between line-ending checks",
7294 path.display()
7295 );
7296 }
7297 let mut pending_cr = false;
7298 loop {
7299 let read = second
7300 .read(&mut buf)
7301 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7302 if read == 0 {
7303 break;
7304 }
7305 for byte in &buf[..read] {
7306 if pending_cr {
7307 if *byte == b'\n' {
7308 object.update(b"\n");
7309 pending_cr = false;
7310 continue;
7311 }
7312 object.update(b"\r");
7313 pending_cr = false;
7314 }
7315 if *byte == b'\r' {
7316 pending_cr = true;
7317 } else {
7318 object.update(std::slice::from_ref(byte));
7319 }
7320 }
7321 }
7322 if pending_cr {
7323 object.update(b"\r");
7324 }
7325 let second_after = second
7326 .metadata()
7327 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7328 let current = std::fs::symlink_metadata(path)
7329 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7330 if !stable_file_metadata(&second_before, &second_after)
7331 || !stable_file_metadata(&second_after, ¤t)
7332 {
7333 bail!(
7334 "tracked file {} changed while line endings were hashed",
7335 path.display()
7336 );
7337 }
7338 Ok((object.finish(), every_lf_was_crlf))
7339}
7340
7341fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7342 let mut options = OpenOptions::new();
7343 options.read(true);
7344 #[cfg(unix)]
7345 {
7346 use std::os::unix::fs::OpenOptionsExt;
7347 options.custom_flags(libc::O_NOFOLLOW);
7348 }
7349 let file = options
7350 .open(path)
7351 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7352 let metadata = file
7353 .metadata()
7354 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7355 if !metadata.is_file() {
7356 bail!("tracked path {} is not a regular file", path.display());
7357 }
7358 Ok(file)
7359}
7360
7361fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7362 if !same_file(left, right)
7363 || left.len() != right.len()
7364 || left.modified().ok() != right.modified().ok()
7365 || left.permissions() != right.permissions()
7366 {
7367 return false;
7368 }
7369 #[cfg(unix)]
7370 {
7371 use std::os::unix::fs::MetadataExt;
7372 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7373 }
7374 #[cfg(not(unix))]
7375 {
7376 left.created().ok() == right.created().ok()
7377 }
7378}
7379
7380const ATTRIBUTE_BATCH: usize = 1000;
7387
7388fn check_attributes(
7389 cwd: &Path,
7390 paths: impl IntoIterator<Item = PathBuf>,
7391) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7392 let paths = paths.into_iter().collect::<BTreeSet<_>>();
7393 if paths.is_empty() {
7394 return Ok(BTreeMap::new());
7395 }
7396 let ordered = paths.into_iter().collect::<Vec<_>>();
7397 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7398 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7399 values.extend(check_attribute_batch(cwd, batch)?);
7400 }
7401 Ok(values)
7402}
7403
7404fn check_attribute_batch(
7405 cwd: &Path,
7406 batch: &[PathBuf],
7407) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7408 const NAMES: [&str; 6] = [
7409 "filter",
7410 "working-tree-encoding",
7411 "ident",
7412 "text",
7413 "eol",
7414 "crlf",
7415 ];
7416 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7417 let mut input = String::new();
7418 for path in &paths {
7419 let path = path.to_str().ok_or_else(|| {
7420 spar_err!(
7421 "cannot inspect attributes for a non-UTF-8 path in {}",
7422 cwd.display()
7423 )
7424 })?;
7425 input.push_str(path);
7426 input.push('\0');
7427 }
7428 let argv = git_without_automation_argv(&[
7429 "check-attr",
7430 "-z",
7431 "--cached",
7432 "--stdin",
7433 "filter",
7434 "working-tree-encoding",
7435 "ident",
7436 "text",
7437 "eol",
7438 "crlf",
7439 ]);
7440 let output = proc::run_bytes(
7441 &argv,
7442 &ExecOpts::new()
7443 .cwd(cwd)
7444 .timeout_secs(30)
7445 .stdin(input)
7446 .stop_descendants(true),
7447 )?;
7448 if !output.is_empty() && !output.ends_with(&[0]) {
7449 bail!(
7450 "git returned an unterminated attribute result for {}",
7451 cwd.display()
7452 );
7453 }
7454 let fields = output
7455 .split(|byte| *byte == 0)
7456 .filter(|field| !field.is_empty())
7457 .collect::<Vec<_>>();
7458 if fields.len() != paths.len() * NAMES.len() * 3 {
7459 bail!(
7460 "git returned an unexpected attribute result for {}",
7461 cwd.display()
7462 );
7463 }
7464 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7465 for record in fields.chunks_exact(3) {
7466 let path = safe_git_path(record[0], "attribute")?;
7467 if !paths.contains(&path) {
7468 bail!(
7469 "git returned attributes for the wrong path in {}",
7470 cwd.display()
7471 );
7472 }
7473 let name = std::str::from_utf8(record[1])
7474 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7475 let value = std::str::from_utf8(record[2])
7476 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7477 values
7478 .entry(path)
7479 .or_default()
7480 .insert(name.to_string(), value.to_string());
7481 }
7482 if paths.iter().any(|path| {
7483 values
7484 .get(path)
7485 .is_none_or(|attributes| attributes.len() != NAMES.len())
7486 }) {
7487 bail!(
7488 "git omitted an attribute result for a tracked path in {}",
7489 cwd.display()
7490 );
7491 }
7492 Ok(values)
7493}
7494
7495fn attribute_is_active(value: Option<&String>) -> bool {
7496 !matches!(
7497 value.map(String::as_str),
7498 None | Some("unspecified") | Some("unset")
7499 )
7500}
7501
7502fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7503 attribute_is_active(values.get("filter"))
7504 || attribute_is_active(values.get("working-tree-encoding"))
7505}
7506
7507struct CheckoutConfig {
7516 autocrlf: Option<String>,
7517 eol: Option<String>,
7518 symlinks: Option<bool>,
7519}
7520
7521impl CheckoutConfig {
7522 fn read(cwd: &Path) -> Result<Self> {
7523 Ok(Self {
7524 autocrlf: git_config_value(cwd, "core.autocrlf")?,
7525 eol: git_config_value(cwd, "core.eol")?,
7526 symlinks: git_config_bool(cwd, "core.symlinks")?,
7527 })
7528 }
7529
7530 fn autocrlf_is_true(&self) -> bool {
7531 self.autocrlf.as_ref().is_some_and(|value| {
7532 matches!(
7533 value.to_ascii_lowercase().as_str(),
7534 "true" | "yes" | "on" | "1"
7535 )
7536 })
7537 }
7538
7539 fn eol_is(&self, wanted: &str) -> bool {
7540 self.eol
7541 .as_ref()
7542 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7543 }
7544}
7545
7546fn path_has_ambiguous_transform(
7547 config: &CheckoutConfig,
7548 values: &BTreeMap<String, String>,
7549) -> Result<bool> {
7550 if path_has_external_transform(values)
7551 || attribute_is_active(values.get("ident"))
7552 || attribute_is_active(values.get("crlf"))
7553 {
7554 return Ok(true);
7555 }
7556 let text = values.get("text").map(String::as_str);
7557 let eol = values.get("eol").map(String::as_str);
7558 if text == Some("auto") {
7559 return Ok(true);
7560 }
7561 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7562 || !matches!(
7563 eol,
7564 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7565 )
7566 {
7567 return Ok(true);
7568 }
7569 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7570 return Ok(config.autocrlf_is_true());
7571 }
7572 Ok(false)
7573}
7574
7575fn allows_expected_crlf(
7576 config: &CheckoutConfig,
7577 values: &BTreeMap<String, String>,
7578) -> Result<bool> {
7579 if path_has_external_transform(values)
7580 || attribute_is_active(values.get("ident"))
7581 || attribute_is_active(values.get("crlf"))
7582 {
7583 return Ok(false);
7584 }
7585 let text = values.get("text").map(String::as_str);
7586 let eol = values.get("eol").map(String::as_str);
7587 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7588 return Ok(false);
7589 }
7590 if eol == Some("crlf") {
7591 return Ok(true);
7592 }
7593 if text != Some("set") {
7594 return Ok(false);
7595 }
7596 if let Some(autocrlf) = config.autocrlf.as_deref() {
7597 match autocrlf.to_ascii_lowercase().as_str() {
7598 "true" | "yes" | "on" | "1" => return Ok(true),
7599 "input" => return Ok(false),
7600 _ => {}
7601 }
7602 }
7603 if config.eol_is("crlf") {
7604 return Ok(true);
7605 }
7606 #[cfg(windows)]
7607 if config.eol.is_none() || config.eol_is("native") {
7608 return Ok(true);
7609 }
7610 Ok(false)
7611}
7612
7613fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7614 let argv = git_without_automation_argv(&["config", "--get", key]);
7615 let output = proc::exec(
7616 &argv,
7617 &ExecOpts::new()
7618 .cwd(cwd)
7619 .timeout_secs(30)
7620 .check(false)
7621 .stop_descendants(true),
7622 )?;
7623 match output.code {
7624 0 => Ok(Some(output.stdout.trim().to_string())),
7625 1 => Ok(None),
7626 _ => bail!(
7627 "could not read Git configuration in {}: {}",
7628 cwd.display(),
7629 output.stderr.trim()
7630 ),
7631 }
7632}
7633
7634fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7635 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7636 let output = proc::exec(
7637 &argv,
7638 &ExecOpts::new()
7639 .cwd(cwd)
7640 .timeout_secs(30)
7641 .check(false)
7642 .stop_descendants(true),
7643 )?;
7644 match output.code {
7645 0 if output.stdout.trim() == "true" => Ok(Some(true)),
7646 0 if output.stdout.trim() == "false" => Ok(Some(false)),
7647 0 => bail!(
7648 "git returned an invalid boolean for {key} in {}",
7649 cwd.display()
7650 ),
7651 1 => Ok(None),
7652 _ => bail!(
7653 "could not read Git configuration in {}: {}",
7654 cwd.display(),
7655 output.stderr.trim()
7656 ),
7657 }
7658}
7659
7660#[cfg(unix)]
7661fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7662 use std::os::unix::fs::PermissionsExt;
7663 if metadata.permissions().mode() & 0o111 == 0 {
7664 "100644".to_string()
7665 } else {
7666 "100755".to_string()
7667 }
7668}
7669
7670#[cfg(not(unix))]
7671fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7672 "100644".to_string()
7673}
7674
7675fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7676 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7677 if !listed.is_empty() && !listed.ends_with(&[0]) {
7678 bail!(
7679 "git returned an unterminated tree listing for {}",
7680 cwd.display()
7681 );
7682 }
7683 let mut entries = Vec::new();
7684 for record in listed
7685 .split(|byte| *byte == 0)
7686 .filter(|record| !record.is_empty())
7687 {
7688 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7689 bail!("git returned a malformed tree record for {}", cwd.display());
7690 };
7691 let fields = record[..tab]
7692 .split(|byte| *byte == b' ')
7693 .collect::<Vec<_>>();
7694 if fields.len() != 3 {
7695 bail!("git returned a malformed tree header for {}", cwd.display());
7696 }
7697 let mode = std::str::from_utf8(fields[0])
7698 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7699 .to_string();
7700 let oid = std::str::from_utf8(fields[2])
7701 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7702 .to_string();
7703 entries.push(IndexEntry {
7704 path: safe_git_path(&record[tab + 1..], "tree")?,
7705 mode,
7706 oid,
7707 });
7708 }
7709 Ok(entries)
7710}
7711
7712fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7713 Ok(tree_entries(cwd, "HEAD")?
7714 .into_iter()
7715 .filter(|entry| entry.mode == "160000")
7716 .map(|entry| (entry.path, entry.oid))
7717 .collect())
7718}
7719
7720fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7721 let head = head_gitlinks(cwd)?;
7722 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7723 .into_iter()
7724 .map(|link| (link.path, link.oid))
7725 .collect();
7726 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7727 paths.extend(index.keys().cloned());
7728 Ok(paths
7729 .into_iter()
7730 .filter(|path| head.get(path) != index.get(path))
7731 .collect())
7732}
7733
7734fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7735 let path = parent.join(relative);
7736 let metadata = match std::fs::symlink_metadata(&path) {
7737 Ok(metadata) => metadata,
7738 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7739 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7740 };
7741 if !metadata.is_dir() {
7742 bail!("the gitlink at {} is not a directory", path.display());
7743 }
7744 let canonical = std::fs::canonicalize(&path)
7745 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7746 if canonical != path {
7747 bail!(
7748 "the gitlink at {} resolves through a symlink",
7749 path.display()
7750 );
7751 }
7752 if !path.join(".git").exists() {
7753 let empty = std::fs::read_dir(&path)
7754 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7755 .next()
7756 .is_none();
7757 if empty {
7758 return Ok(None);
7759 }
7760 bail!(
7761 "the uninitialized gitlink at {} contains local files",
7762 path.display()
7763 );
7764 }
7765 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7766 if inside.trim() != "true" {
7767 bail!("the gitlink at {} is not a worktree", path.display());
7768 }
7769 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7770 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7771 spar_err!(
7772 "could not resolve the gitlink top level at {}: {e}",
7773 path.display()
7774 )
7775 })?;
7776 if top != canonical {
7777 bail!(
7778 "the gitlink at {} belongs to a different worktree",
7779 path.display()
7780 );
7781 }
7782 Ok(Some(canonical))
7783}
7784
7785fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
7799 let root = std::fs::canonicalize(cwd)
7800 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7801 let mut allowed = BTreeSet::from([root.join(".git")]);
7802 let mut checkouts = BTreeSet::new();
7803 let mut repositories = vec![root.clone()];
7804 let mut visited = BTreeSet::new();
7805 while let Some(repository) = repositories.pop() {
7806 let canonical = std::fs::canonicalize(&repository)
7807 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7808 if !visited.insert(canonical.clone()) {
7809 bail!("submodule recursion revisited {}", canonical.display());
7810 }
7811 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
7812 let (path, nested) = untracked_record(&raw, "untracked")?;
7813 if nested {
7814 checkouts.insert(canonical.join(path));
7815 }
7816 }
7817 for link in gitlinks(&canonical)? {
7818 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
7819 continue;
7820 };
7821 allowed.insert(submodule.join(".git"));
7822 repositories.push(submodule);
7823 }
7824 }
7825
7826 let scan_root = root.clone();
7830 let mut directories = vec![root];
7831 while let Some(directory) = directories.pop() {
7832 let entries = match std::fs::read_dir(&directory) {
7833 Ok(entries) => entries,
7834 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7835 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7836 };
7837 for entry in entries {
7838 let entry = match entry {
7839 Ok(entry) => entry,
7840 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7841 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7842 };
7843 let path = entry.path();
7844 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
7845 continue;
7846 }
7847 if entry.file_name() == OsStr::new(".git") {
7848 if !allowed.contains(&path) {
7849 return Ok(Some(path));
7850 }
7851 continue;
7852 }
7853 let kind = match entry.file_type() {
7854 Ok(kind) => kind,
7855 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7856 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7857 };
7858 if kind.is_dir() && !checkouts.contains(&path) {
7859 directories.push(path);
7860 }
7861 }
7862 }
7863 Ok(None)
7864}
7865
7866pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
7867 if let Some(path) = unexpected_nested_git_entry(cwd)? {
7868 bail!(
7869 "the worktree contains a Git entry at {} that Git does not report as a nested \
7870 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
7871 prove they are unchanged.",
7872 path.display()
7873 );
7874 }
7875 let root = std::fs::canonicalize(cwd)
7876 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7877 let mut repositories = BTreeMap::new();
7878 let mut visited = BTreeSet::new();
7879 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7880 Ok(GitState { repositories })
7881}
7882
7883fn collect_git_state(
7884 repository: &Path,
7885 prefix: &Path,
7886 visited: &mut BTreeSet<PathBuf>,
7887 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7888) -> Result<()> {
7889 let canonical = std::fs::canonicalize(repository)
7890 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7891 if !visited.insert(canonical.clone()) {
7892 bail!("submodule recursion revisited {}", canonical.display());
7893 }
7894 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7895 let head = head.trim().to_string();
7896 if head.is_empty() {
7897 bail!("git returned an empty head for {}", repository.display());
7898 }
7899 let unsafe_index_flags = unsafe_index_flags(repository)?;
7900 let tracked = tracked_entries(repository)?;
7901 let gitlinks = gitlinks(repository)?;
7902 if repositories
7903 .insert(
7904 prefix.to_path_buf(),
7905 RepositoryState {
7906 head,
7907 unsafe_index_flags,
7908 tracked,
7909 gitlinks: gitlinks
7910 .iter()
7911 .map(|link| (link.path.clone(), link.oid.clone()))
7912 .collect(),
7913 },
7914 )
7915 .is_some()
7916 {
7917 bail!("Git state contains duplicate repository path {:?}", prefix);
7918 }
7919
7920 for link in gitlinks {
7921 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7922 continue;
7923 };
7924 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7925 }
7926 Ok(())
7927}
7928
7929fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7930 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7931 if !listed.is_empty() && !listed.ends_with(&[0]) {
7932 bail!(
7933 "git returned an unterminated index-flag listing for {}",
7934 cwd.display()
7935 );
7936 }
7937 let mut unsafe_records = Vec::new();
7938 for record in listed
7939 .split(|byte| *byte == 0)
7940 .filter(|record| !record.is_empty())
7941 {
7942 if record.len() < 3 || record[1] != b' ' {
7943 bail!(
7944 "git returned a malformed index-flag record for {}",
7945 cwd.display()
7946 );
7947 }
7948 if record[0] != b'H' {
7949 unsafe_records.extend_from_slice(record);
7950 unsafe_records.push(0);
7951 }
7952 }
7953 Ok(unsafe_records)
7954}
7955
7956pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7957 safe_git_state(cwd).map(|_| ())
7958}
7959
7960pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7961 let state = git_state(cwd)?;
7962 if let Some((path, _repository)) = state
7963 .repositories
7964 .iter()
7965 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7966 {
7967 let label = if path.as_os_str().is_empty() {
7968 cwd.to_path_buf()
7969 } else {
7970 cwd.join(path)
7971 };
7972 bail!(
7973 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7974 SPAR cannot prove the working files are unchanged, so it was kept.",
7975 label.display()
7976 );
7977 }
7978 Ok(state)
7979}
7980
7981fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7982 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7983}
7984
7985fn repository_has_recoverable_work_with(
7986 cwd: &Path,
7987 include_ignored: bool,
7988 disposable: Disposable,
7989) -> Result<bool> {
7990 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7991 return Ok(true);
7992 }
7993 let mut visited = BTreeSet::new();
7994 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7995}
7996
7997fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7998 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7999 let git_dir = PathBuf::from(git_dir.trim());
8000 let git_dir = if git_dir.is_absolute() {
8001 git_dir
8002 } else {
8003 cwd.join(git_dir)
8004 };
8005 let git_dir = std::fs::canonicalize(&git_dir)
8006 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8007 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8008 Ok(_) => return Ok(true),
8009 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8010 Err(error) => {
8011 return Err(spar_err!(
8012 "could not inspect per-worktree configuration in {}: {error}",
8013 git_dir.display()
8014 ))
8015 }
8016 }
8017
8018 let orig_head = git_dir.join("ORIG_HEAD");
8019 match std::fs::symlink_metadata(&orig_head) {
8020 Ok(metadata) if metadata.is_file() => {
8021 let oid = std::fs::read_to_string(&orig_head)
8022 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8023 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8024 return Ok(true);
8025 };
8026 if !commit_has_shared_ref(cwd, &commit)? {
8027 return Ok(true);
8028 }
8029 }
8030 Ok(_) => return Ok(true),
8031 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8032 Err(error) => {
8033 return Err(spar_err!(
8034 "could not inspect {}: {error}",
8035 orig_head.display()
8036 ))
8037 }
8038 }
8039
8040 let edit_message = git_dir.join("COMMIT_EDITMSG");
8041 match std::fs::symlink_metadata(&edit_message) {
8042 Ok(metadata) if metadata.is_file() => {
8043 let draft = std::fs::read(&edit_message)
8044 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8045 if draft != head_commit_message(cwd)? {
8046 return Ok(true);
8047 }
8048 }
8049 Ok(_) => return Ok(true),
8050 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8051 Err(error) => {
8052 return Err(spar_err!(
8053 "could not inspect {}: {error}",
8054 edit_message.display()
8055 ))
8056 }
8057 }
8058
8059 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8060 return Ok(true);
8061 }
8062
8063 let local_refs = run_git_bytes(
8064 cwd,
8065 &[
8066 "for-each-ref",
8067 "--format=%(refname)",
8068 "refs/worktree",
8069 "refs/bisect",
8070 "refs/rewritten",
8071 ],
8072 )?;
8073 if !local_refs.is_empty() {
8074 return Ok(true);
8075 }
8076
8077 for entry in std::fs::read_dir(&git_dir)
8081 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8082 {
8083 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8084 let known = matches!(
8085 entry.file_name().to_str(),
8086 Some(
8087 "HEAD"
8088 | "ORIG_HEAD"
8089 | "COMMIT_EDITMSG"
8090 | "FETCH_HEAD"
8091 | "commondir"
8092 | "gitdir"
8093 | "index"
8094 | "logs"
8095 | "refs"
8096 )
8097 );
8098 if !known {
8099 return Ok(true);
8100 }
8101 }
8102
8103 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8104 if !commit_has_shared_ref(cwd, head.trim())? {
8105 return Ok(true);
8106 }
8107 Ok(false)
8108}
8109
8110fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8111 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8112 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8113 bail!(
8114 "git returned a commit without a message separator in {}",
8115 cwd.display()
8116 );
8117 };
8118 Ok(commit[split + 2..].to_vec())
8119}
8120
8121fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8122 let metadata = match std::fs::symlink_metadata(logs) {
8123 Ok(metadata) => metadata,
8124 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8125 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8126 };
8127 if !metadata.is_dir() {
8128 return Ok(true);
8129 }
8130 let mut files = Vec::new();
8131 let mut directories = vec![logs.to_path_buf()];
8132 while let Some(directory) = directories.pop() {
8133 for entry in std::fs::read_dir(&directory)
8134 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8135 {
8136 let entry =
8137 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8138 let path = entry.path();
8139 let kind = entry
8140 .file_type()
8141 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8142 if kind.is_dir() {
8143 directories.push(path);
8144 } else if kind.is_file() {
8145 files.push(path);
8146 } else {
8147 return Ok(true);
8148 }
8149 }
8150 }
8151
8152 let mut commits = BTreeSet::new();
8153 for path in files {
8154 if !collect_reflog_commits(cwd, &path, &mut commits)? {
8155 return Ok(true);
8156 }
8157 }
8158 for commit in commits {
8159 if !commit_has_shared_ref(cwd, &commit)? {
8160 return Ok(true);
8161 }
8162 }
8163 Ok(false)
8164}
8165
8166fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8170 let common = common_git_dir(cwd)?;
8171 let reflog = common.join("logs").join(refname);
8172 let metadata = match std::fs::symlink_metadata(&reflog) {
8173 Ok(metadata) => metadata,
8174 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8175 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8176 };
8177 if !metadata.is_file() {
8178 return Ok(false);
8179 }
8180 let mut commits = BTreeSet::new();
8181 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8182 return Ok(false);
8183 }
8184 for commit in commits {
8185 if is_ancestor(cwd, &commit, durable_tip)?
8186 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8187 {
8188 continue;
8189 }
8190 return Ok(false);
8191 }
8192 Ok(true)
8193}
8194
8195fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8196 let file = std::fs::File::open(path)
8197 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8198 for line in std::io::BufReader::new(file).lines() {
8199 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8200 let mut fields = line.splitn(3, ' ');
8201 let Some(old) = fields.next() else {
8202 return Ok(false);
8203 };
8204 let Some(new) = fields.next() else {
8205 return Ok(false);
8206 };
8207 if fields.next().is_none() {
8208 return Ok(false);
8209 }
8210 for oid in [old, new] {
8211 if oid.bytes().all(|byte| byte == b'0') {
8212 continue;
8213 }
8214 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8215 return Ok(false);
8216 };
8217 commits.insert(commit);
8218 }
8219 }
8220 Ok(true)
8221}
8222
8223fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8224 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8225 let path = PathBuf::from(raw.trim());
8226 let path = if path.is_absolute() {
8227 path
8228 } else {
8229 cwd.join(path)
8230 };
8231 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8232}
8233
8234fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8235 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8236 let output = proc::exec(
8237 &argv,
8238 &ExecOpts::new()
8239 .cwd(cwd)
8240 .timeout_secs(30)
8241 .check(false)
8242 .stop_descendants(true),
8243 )?;
8244 match output.code {
8245 0 => Ok(true),
8246 1 => Ok(false),
8247 _ => bail!("{}", proc::failure_message(&argv, &output)),
8248 }
8249}
8250
8251fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8252 let commit = format!("{oid}^{{commit}}");
8253 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8254 let output = proc::exec(
8255 &argv,
8256 &ExecOpts::new()
8257 .cwd(cwd)
8258 .timeout_secs(30)
8259 .check(false)
8260 .stop_descendants(true),
8261 )?;
8262 if output.code != 0 {
8263 return Ok(None);
8264 }
8265 let oid = output.stdout.trim();
8266 if oid.is_empty() {
8267 return Ok(None);
8268 }
8269 Ok(Some(oid.to_string()))
8270}
8271
8272fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8273 commit_has_shared_ref_except(cwd, oid, None)
8274}
8275
8276fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8277 let contains = format!("--contains={oid}");
8278 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8279 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8280 !record.is_empty()
8281 && !record.starts_with(b"refs/worktree/")
8282 && !record.starts_with(b"refs/bisect/")
8283 && !record.starts_with(b"refs/rewritten/")
8284 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8285 }))
8286}
8287
8288fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8303 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8304 for raw in &ordinary {
8305 let (path, _) = untracked_record(raw, "untracked")?;
8306 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8307 continue;
8308 }
8309 return Ok(true);
8310 }
8311 let listed = untracked_listing(
8312 cwd,
8313 &[
8314 "ls-files",
8315 "--others",
8316 "--ignored",
8317 "--exclude-standard",
8318 "-z",
8319 ],
8320 )?;
8321 for raw in listed {
8322 let (path, nested) = untracked_record(&raw, "ignored")?;
8323 if nested {
8327 return Ok(true);
8328 }
8329 if disposable == Disposable::BuildOutput
8330 && !is_generated_artifact(&path)
8331 && !is_desktop_metadata(&path)
8332 {
8333 return Ok(true);
8334 }
8335 }
8336 Ok(false)
8337}
8338
8339fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8340 let listed = run_git_bytes(cwd, args)?;
8341 if !listed.is_empty() && !listed.ends_with(&[0]) {
8342 bail!(
8343 "git returned an unterminated untracked-file list for {}",
8344 cwd.display()
8345 );
8346 }
8347 Ok(listed
8348 .split(|byte| *byte == 0)
8349 .filter(|raw| !raw.is_empty())
8350 .map(|raw| raw.to_vec())
8351 .collect())
8352}
8353
8354fn repository_has_recoverable_work_inner(
8355 cwd: &Path,
8356 include_ignored: bool,
8357 disposable: Disposable,
8358 visited: &mut BTreeSet<PathBuf>,
8359) -> Result<bool> {
8360 let canonical = std::fs::canonicalize(cwd)
8361 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8362 if !visited.insert(canonical.clone()) {
8363 bail!("submodule recursion revisited {}", canonical.display());
8364 }
8365 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8366 return Ok(true);
8367 }
8368 if !unsafe_index_flags(cwd)?.is_empty() {
8369 return Ok(true);
8370 }
8371 if attributes_may_be_modified(cwd)? {
8372 return Ok(true);
8373 }
8374 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8375 return Ok(true);
8376 }
8377 if include_ignored {
8378 let index = index_entries(cwd)?
8379 .into_iter()
8380 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8381 .collect::<BTreeMap<_, _>>();
8382 let head = tree_entries(cwd, "HEAD")?
8383 .into_iter()
8384 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8385 .collect::<BTreeMap<_, _>>();
8386 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8387 return Ok(true);
8388 }
8389 let tracked = tracked_entries(cwd)?;
8390 let effective = check_attributes(cwd, tracked.keys().cloned())?;
8391 let config = CheckoutConfig::read(cwd)?;
8392 for (path, entry) in tracked {
8393 let Some(worktree) = entry.worktree else {
8394 return Ok(true);
8395 };
8396 let attributes = effective.get(&path).ok_or_else(|| {
8397 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8398 })?;
8399 if path_has_ambiguous_transform(&config, attributes)? {
8400 return Ok(true);
8401 }
8402 let symlink_file = entry.index_mode == "120000"
8403 && worktree.mode == "100644"
8404 && worktree.raw_oid == entry.index_oid
8405 && config.symlinks == Some(false);
8406 if worktree.mode != entry.index_mode && !symlink_file {
8407 return Ok(true);
8408 }
8409 if entry.index_mode == "120000" {
8410 if worktree.raw_oid != entry.index_oid {
8411 return Ok(true);
8412 }
8413 continue;
8414 }
8415 #[cfg(unix)]
8416 {
8417 let expected = if entry.index_mode == "100755" {
8418 0o755
8419 } else {
8420 0o644
8421 };
8422 if worktree.permissions != expected {
8423 return Ok(true);
8424 }
8425 }
8426 if allows_expected_crlf(&config, attributes)? {
8427 let (normalized, every_lf_was_crlf) =
8428 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8429 if !every_lf_was_crlf || normalized != entry.index_oid {
8430 return Ok(true);
8431 }
8432 } else if worktree.raw_oid != entry.index_oid {
8433 return Ok(true);
8434 }
8435 }
8436 } else {
8437 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8438 if !run_git_bytes(cwd, &args)?.is_empty() {
8439 return Ok(true);
8440 }
8441 }
8442 for link in gitlinks(cwd)? {
8443 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8444 continue;
8445 };
8446 if include_ignored {
8451 return Ok(true);
8452 }
8453 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8454 if head.trim() != link.oid {
8455 return Ok(true);
8456 }
8457 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8458 {
8459 return Ok(true);
8460 }
8461 }
8462 Ok(false)
8463}
8464
8465pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8466 repository_has_recoverable_work(cwd, false)
8467}
8468
8469fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8470 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8471 Ok(!run_git_bytes(cwd, &args)?.is_empty())
8472}
8473
8474#[cfg(unix)]
8475fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8476 use std::os::unix::ffi::OsStringExt;
8477 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8478}
8479
8480#[cfg(not(unix))]
8481fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8482 String::from_utf8(raw.to_vec())
8483 .map(PathBuf::from)
8484 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8485}
8486
8487fn vanished_untracked_file() -> UntrackedFile {
8503 UntrackedFile {
8504 kind: 0,
8505 len: 0,
8506 modified: None,
8507 created: None,
8508 readonly: false,
8509 symlink_target: None,
8510 #[cfg(unix)]
8511 device: 0,
8512 #[cfg(unix)]
8513 inode: 0,
8514 #[cfg(unix)]
8515 mode: 0,
8516 #[cfg(unix)]
8517 change_seconds: 0,
8518 #[cfg(unix)]
8519 change_nanoseconds: 0,
8520 }
8521}
8522
8523fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8524 let metadata = match std::fs::symlink_metadata(path) {
8525 Ok(metadata) => metadata,
8526 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8527 Err(e) => {
8528 return Err(spar_err!(
8529 "could not inspect the nested repository at {}: {e}",
8530 path.display()
8531 ))
8532 }
8533 };
8534 if !metadata.is_dir() {
8535 bail!(
8536 "git reported {} as a nested repository, but it is not a directory",
8537 path.display()
8538 );
8539 }
8540 if std::fs::symlink_metadata(path.join(".git")).is_err() {
8541 bail!(
8542 "git reported {} as a nested repository, but it has no Git entry",
8543 path.display()
8544 );
8545 }
8546 #[cfg(unix)]
8547 {
8548 use std::os::unix::fs::MetadataExt;
8549 Ok(UntrackedFile {
8550 kind: 3,
8551 len: 0,
8552 modified: None,
8553 created: metadata.created().ok(),
8554 readonly: metadata.permissions().readonly(),
8555 symlink_target: None,
8556 device: metadata.dev(),
8557 inode: metadata.ino(),
8558 mode: metadata.mode(),
8559 change_seconds: 0,
8560 change_nanoseconds: 0,
8561 })
8562 }
8563 #[cfg(not(unix))]
8564 {
8565 Ok(UntrackedFile {
8566 kind: 3,
8567 len: 0,
8568 modified: None,
8569 created: metadata.created().ok(),
8570 readonly: metadata.permissions().readonly(),
8571 symlink_target: None,
8572 })
8573 }
8574}
8575
8576fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8577 let metadata = match std::fs::symlink_metadata(path) {
8578 Ok(metadata) => metadata,
8579 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8580 Err(e) => {
8581 return Err(spar_err!(
8582 "could not inspect untracked file {}: {e}",
8583 path.display()
8584 ))
8585 }
8586 };
8587 let kind = if metadata.file_type().is_symlink() {
8588 2
8589 } else if metadata.is_file() {
8590 1
8591 } else {
8592 bail!(
8593 "untracked path {} is not a regular file or symlink",
8594 path.display()
8595 );
8596 };
8597 let symlink_target = if kind == 2 {
8598 match std::fs::read_link(path) {
8599 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8600 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8601 return Ok(vanished_untracked_file())
8602 }
8603 Err(e) => {
8604 return Err(spar_err!(
8605 "could not read untracked symlink {}: {e}",
8606 path.display()
8607 ))
8608 }
8609 }
8610 } else {
8611 None
8612 };
8613 #[cfg(unix)]
8614 {
8615 use std::os::unix::fs::MetadataExt;
8616 Ok(UntrackedFile {
8617 kind,
8618 len: metadata.len(),
8619 modified: metadata.modified().ok(),
8620 created: metadata.created().ok(),
8621 readonly: metadata.permissions().readonly(),
8622 symlink_target,
8623 device: metadata.dev(),
8624 inode: metadata.ino(),
8625 mode: metadata.mode(),
8626 change_seconds: metadata.ctime(),
8627 change_nanoseconds: metadata.ctime_nsec(),
8628 })
8629 }
8630 #[cfg(not(unix))]
8631 {
8632 Ok(UntrackedFile {
8633 kind,
8634 len: metadata.len(),
8635 modified: metadata.modified().ok(),
8636 created: metadata.created().ok(),
8637 readonly: metadata.permissions().readonly(),
8638 symlink_target,
8639 })
8640 }
8641}
8642
8643#[cfg(unix)]
8644fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8645 use std::os::unix::ffi::OsStrExt;
8646 Ok(value.as_bytes().to_vec())
8647}
8648
8649#[cfg(not(unix))]
8650fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8651 value
8652 .to_str()
8653 .map(|value| value.as_bytes().to_vec())
8654 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8655}
8656
8657#[cfg(unix)]
8658fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8659 use std::os::unix::fs::MetadataExt;
8660 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8661}
8662
8663#[cfg(not(unix))]
8664fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8665 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8666}
8667
8668#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8669pub struct BranchRecord {
8670 pub kind: String,
8671 pub number: i64,
8672}
8673
8674pub fn review_ref(number: i64) -> String {
8677 format!("refs/spar/pr-{number}")
8678}
8679
8680pub fn is_finished(state: &str) -> bool {
8681 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8682}
8683
8684pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8691 if let Some(parent) = path.parent() {
8692 std::fs::create_dir_all(parent)
8693 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8694 }
8695 let tmp = path.with_extension(format!(
8698 "{}.tmp",
8699 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8700 ));
8701 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8702 std::fs::rename(&tmp, path)
8703 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8704 Ok(())
8705}
8706
8707pub fn has_heading(text: &str, title: &str) -> bool {
8714 let wanted = title.trim().to_lowercase();
8715 if wanted.is_empty() {
8716 return false;
8717 }
8718 text.lines()
8719 .filter_map(|line| line.trim().strip_prefix("## "))
8720 .any(|heading| heading.trim().to_lowercase() == wanted)
8721}
8722
8723pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8726 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8727}
8728
8729#[derive(Deserialize)]
8731#[serde(rename_all = "camelCase")]
8732pub struct LinkedPrRow {
8733 number: i64,
8734 #[serde(default)]
8735 url: String,
8736 #[serde(default)]
8737 title: String,
8738 #[serde(default)]
8739 closing_issues_references: Vec<IssueRef>,
8740}
8741
8742pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8749 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8750}
8751
8752pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8754 rows.iter()
8755 .find(|row| {
8756 row.closing_issues_references
8757 .iter()
8758 .any(|linked| linked.number == issue)
8759 })
8760 .map(|row| PrRef {
8761 number: row.number,
8762 url: row.url.clone(),
8763 title: row.title.clone(),
8764 })
8765}
8766
8767pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8769 pick_linked_pr(&linked_pr_rows(json)?, issue)
8770}
8771
8772pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8779 if text.trim().is_empty() {
8780 return Err(spar_err!("GitHub returned no comment data"));
8781 }
8782 let mut out = Vec::new();
8783 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8784 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8785 Value::Array(items) => out.extend(items),
8786 _ => return Err(spar_err!("unexpected non-array comment page")),
8787 }
8788 }
8789 Ok(out)
8790}
8791
8792pub fn parse_comment_pages(text: &str) -> Vec<Value> {
8793 let mut out = Vec::new();
8794 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8795 match value {
8796 Ok(Value::Array(items)) => out.extend(items),
8797 Ok(other) => out.push(other),
8798 Err(_) => break,
8799 }
8800 }
8801 out
8802}
8803
8804pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
8812 body.trim_start().starts_with(STATE_MARKER)
8813 && !viewer.trim().is_empty()
8814 && author.trim().eq_ignore_ascii_case(viewer.trim())
8815 && parse_state_comment(body).is_some()
8816}
8817
8818pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
8821 let marker = body.find(STATE_MARKER)?;
8822 let start = body[marker..].find('{')? + marker;
8823 let end = body.rfind('}')?;
8824 if end <= start {
8825 return None;
8826 }
8827 match serde_json::from_str(&body[start..=end]) {
8828 Ok(state) => Some(state),
8829 Err(_) => {
8830 logdim!("found a spar state comment but could not parse it");
8831 None
8832 }
8833 }
8834}
8835
8836fn choose_state_for_head(
8837 candidates: Vec<PersistedState>,
8838 actual_head: &str,
8839) -> Option<PersistedState> {
8840 let matching: Vec<PersistedState> = candidates
8841 .iter()
8842 .filter(|state| state.pr_head == actual_head)
8843 .cloned()
8844 .collect();
8845 if !matching.is_empty() {
8846 return newest_state(matching);
8847 }
8848 newest_state(candidates)
8849}
8850
8851fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
8852 candidates.into_iter().reduce(|best, candidate| {
8853 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
8854 candidate
8855 } else {
8856 best
8861 }
8862 })
8863}
8864
8865pub fn self_binary() -> Result<PathBuf> {
8871 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
8872 let path = PathBuf::from(path);
8873 if proc::is_executable(&path) {
8874 return Ok(path);
8875 }
8876 bail!(
8877 "SPAR_SELF_BIN is set to {}, which is not executable",
8878 path.display()
8879 );
8880 }
8881 std::env::current_exe()
8882 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8883}
8884
8885fn bool_env(value: bool) -> &'static str {
8886 if value {
8887 "1"
8888 } else {
8889 "0"
8890 }
8891}
8892
8893pub fn sh_quote(text: &str) -> String {
8896 format!("'{}'", text.replace('\'', r"'\''"))
8897}
8898
8899pub fn style_from_env() -> Style {
8902 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8903 Style {
8904 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8905 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8906 ..Style::permissive()
8907 }
8908}
8909
8910#[cfg(test)]
8911mod tests {
8912 use super::*;
8913 use crate::config::StateStore;
8914 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8915 use std::process::Command;
8916
8917 fn repo_for_titles() -> Repo {
8918 Repo {
8919 root: PathBuf::from("/nonexistent"),
8920 style: Style::default(),
8921 branch_prefix: String::new(),
8922 state_store: StateStore::Local,
8923 followups: crate::config::Followups::Issues,
8924 drafts: Drafts::Never,
8925 fork_when_denied: true,
8926 setup: CheckCommand::default(),
8927 fork: OnceLock::new(),
8928 viewer: OnceLock::new(),
8929 checkpoints: Mutex::new(BTreeMap::new()),
8930 writes: WriteStats::default(),
8931 }
8932 }
8933
8934 #[test]
8935 fn write_results_accumulate_for_the_run() {
8936 let repo = repo_for_titles();
8937
8938 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8939 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8940
8941 assert_eq!(
8942 WriteSummary {
8943 attempted: 2,
8944 failed: 1,
8945 },
8946 repo.write_summary()
8947 );
8948 }
8949
8950 #[test]
8951 fn only_failed_write_preflights_join_the_summary() {
8952 let repo = repo_for_titles();
8953
8954 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8955 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8956
8957 assert_eq!(
8958 WriteSummary {
8959 attempted: 1,
8960 failed: 1,
8961 },
8962 repo.write_summary()
8963 );
8964 }
8965
8966 #[test]
8967 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8968 let repo = repo_for_titles();
8969
8970 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8971 assert_eq!(
8972 WriteSummary {
8973 attempted: 1,
8974 failed: 1,
8975 },
8976 repo.write_summary()
8977 );
8978 }
8979
8980 #[test]
8981 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8982 let mut repo = repo_for_titles();
8983 repo.followups = Followups::Local;
8984
8985 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8986 assert_eq!(WriteSummary::default(), repo.write_summary());
8987 }
8988
8989 #[test]
8990 fn a_failed_remote_state_read_stops_before_state_mutation() {
8991 let root = std::env::temp_dir().join(format!(
8992 "spar-state-preflight-{}-{}",
8993 std::process::id(),
8994 std::time::SystemTime::now()
8995 .duration_since(std::time::UNIX_EPOCH)
8996 .unwrap()
8997 .as_nanos()
8998 ));
8999 std::fs::create_dir_all(&root).unwrap();
9000 let _fixture = ReviewFixture { root: root.clone() };
9001 let mut repo = repo_for_titles();
9002 repo.root = root;
9003 repo.state_store = StateStore::Both;
9004 let state = PersistedState {
9005 version: 1,
9006 checkpoint: 4,
9007 round: 2,
9008 next_actor: "a".into(),
9009 status: Status::Pending,
9010 pr_head: "abc123".into(),
9011 ledger: Ledger::new(),
9012 filed: Vec::new(),
9013 open_findings: Vec::new(),
9014 disputes: Vec::new(),
9015 noted: Vec::new(),
9016 };
9017
9018 let error = repo
9019 .write_state_after_remote_read(
9020 7,
9021 &state,
9022 Err(crate::error::SparError::new("state comments unavailable")),
9023 )
9024 .unwrap_err();
9025
9026 assert!(error.to_string().contains("state comments unavailable"));
9027 assert!(!repo.state_path(7).exists());
9028 assert_eq!(0, repo.remembered_checkpoint(7));
9029 assert_eq!(
9030 WriteSummary {
9031 attempted: 1,
9032 failed: 1,
9033 },
9034 repo.write_summary()
9035 );
9036 }
9037
9038 #[test]
9039 fn only_known_build_and_cache_directories_are_generated_artifacts() {
9040 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9041 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9042 assert!(is_generated_artifact(Path::new(
9043 "package/node_modules/dependency/file.js"
9044 )));
9045 assert!(!is_generated_artifact(Path::new(
9046 "distribution/required-package.js"
9047 )));
9048 assert!(!is_generated_artifact(Path::new(
9049 "generated/required-fixture.txt"
9050 )));
9051 assert!(!is_generated_artifact(Path::new("local.env")));
9052 }
9053
9054 #[test]
9055 fn only_finder_state_files_are_desktop_metadata() {
9056 assert!(is_desktop_metadata(Path::new(".DS_Store")));
9057 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9058 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9059 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9060 }
9061
9062 struct ReviewFixture {
9063 root: PathBuf,
9064 }
9065
9066 impl Drop for ReviewFixture {
9067 fn drop(&mut self) {
9068 let _ = std::fs::remove_dir_all(&self.root);
9069 }
9070 }
9071
9072 fn test_git(cwd: &Path, args: &[&str]) -> String {
9073 let output = Command::new("git")
9074 .args(args)
9075 .current_dir(cwd)
9076 .output()
9077 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9078 assert!(
9079 output.status.success(),
9080 "git {args:?} failed: {}",
9081 String::from_utf8_lossy(&output.stderr)
9082 );
9083 String::from_utf8_lossy(&output.stdout).into_owned()
9084 }
9085
9086 #[test]
9090 fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9091 let root =
9092 std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9093 let _cleanup = ReviewFixture { root: root.clone() };
9094 let origin = root.join("origin.git");
9095 let work = root.join("work");
9096 let marker = root.join("textconv-ran");
9097 std::fs::create_dir_all(&origin).unwrap();
9098 std::fs::create_dir_all(&work).unwrap();
9099 test_git(&origin, &["init", "--bare", "-b", "main"]);
9100 test_git(&work, &["init", "-b", "main"]);
9101 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9102 test_git(&work, &["config", "user.name", "spar test"]);
9103 test_git(&work, &["config", "commit.gpgsign", "false"]);
9104 test_git(&work, &["config", "diff.noprefix", "true"]);
9105 test_git(&work, &["config", "diff.context", "0"]);
9106 test_git(&work, &["config", "diff.interHunkContext", "10"]);
9107 test_git(&work, &["config", "diff.algorithm", "patience"]);
9108 test_git(&work, &["config", "color.diff", "always"]);
9109 test_git(
9110 &work,
9111 &[
9112 "config",
9113 "diff.conv.textconv",
9114 &format!("touch {}; cat", marker.display()),
9115 ],
9116 );
9117 std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9118 let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9119 std::fs::write(work.join("src.txt"), &lines).unwrap();
9120 test_git(&work, &["add", "."]);
9121 test_git(&work, &["commit", "-m", "seed"]);
9122 test_git(
9123 &work,
9124 &["remote", "add", "origin", origin.to_str().unwrap()],
9125 );
9126 test_git(&work, &["push", "-u", "origin", "main"]);
9127 test_git(&work, &["checkout", "-b", "feature"]);
9128 std::fs::write(
9129 work.join("src.txt"),
9130 lines
9131 .replace("line 5\n", "changed 5\n")
9132 .replace("line 15\n", "changed 15\n"),
9133 )
9134 .unwrap();
9135 std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9136 std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9137 test_git(&work, &["add", "."]);
9138 test_git(&work, &["commit", "-m", "change"]);
9139 let cfg = crate::config::parse(
9140 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9141 )
9142 .unwrap();
9143 let repo = Repo::open(&work, &cfg).unwrap();
9144 assert_eq!(
9145 Hunks::from([
9146 ("new.txt".to_string(), vec![(1, 2)]),
9147 ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9148 ("with space.txt".to_string(), vec![(1, 1)]),
9149 ]),
9150 repo.diff_hunks(&work, "main")
9151 );
9152 assert!(!marker.exists(), "the text converter ran");
9153 test_git(&work, &["diff", "origin/main...HEAD"]);
9155 assert!(marker.exists(), "the fixture's converter never runs at all");
9156 }
9157
9158 fn review_fixture(
9159 tag: &str,
9160 number: i64,
9161 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9162 use std::sync::atomic::{AtomicU32, Ordering};
9163 static NEXT: AtomicU32 = AtomicU32::new(0);
9164 let id = NEXT.fetch_add(1, Ordering::Relaxed);
9165 let root =
9166 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9167 let origin = root.join("origin.git");
9168 let work = root.join("work");
9169 std::fs::create_dir_all(&origin).unwrap();
9170 std::fs::create_dir_all(&work).unwrap();
9171 test_git(&origin, &["init", "--bare", "-b", "main"]);
9172 test_git(&work, &["init", "-b", "main"]);
9173 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9174 test_git(&work, &["config", "user.name", "spar test"]);
9175 test_git(&work, &["config", "commit.gpgsign", "false"]);
9176 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9177 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9178 std::fs::write(work.join("README.md"), "seed\n").unwrap();
9179 std::fs::write(work.join("data.txt"), "old\n").unwrap();
9180 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9181 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9182 test_git(&work, &["add", "."]);
9183 test_git(&work, &["commit", "-m", "seed"]);
9184 test_git(
9185 &work,
9186 &["remote", "add", "origin", origin.to_str().unwrap()],
9187 );
9188 test_git(&work, &["push", "-u", "origin", "main"]);
9189 test_git(
9190 &work,
9191 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9192 );
9193 let cfg = crate::config::parse(
9194 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9195 )
9196 .unwrap();
9197 let repo = Repo::open(&work, &cfg).unwrap();
9198 let path = repo.worktree_for_pr_head(number).unwrap();
9199 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9200 (ReviewFixture { root }, repo, path, checkpoint)
9201 }
9202
9203 #[test]
9204 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9205 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9206
9207 repo.release_review_worktree_checked(901, &checkpoint)
9208 .unwrap();
9209
9210 assert!(!path.exists());
9211 }
9212
9213 #[test]
9214 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9215 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9216 let work = fixture.root.join("work");
9217 let seed = test_git(&work, &["rev-parse", "HEAD"]);
9218 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9219 let publish = |message: &str| {
9222 let oid = test_git(
9223 &work,
9224 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9225 );
9226 let oid = oid.trim().to_string();
9227 test_git(
9228 &work,
9229 &[
9230 "push",
9231 "--force",
9232 "origin",
9233 &format!("{oid}:refs/pull/923/head"),
9234 ],
9235 );
9236 oid
9237 };
9238 let reviewed = publish("reviewed head");
9239 repo.worktree_for_pr_head(923).unwrap();
9240 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9241
9242 let rewritten = publish("rewritten head");
9243 repo.worktree_for_pr_head(923).unwrap();
9244
9245 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9246 }
9247
9248 #[test]
9249 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9250 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9251 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9252 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9253 test_git(&path, &["add", "recovery.txt"]);
9254 test_git(&path, &["commit", "-m", "recovery commit"]);
9255 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9256 test_git(&path, &["reset", "--hard", "main"]);
9257
9258 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9259 test_git(
9260 &path,
9261 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9262 );
9263 }
9264
9265 #[test]
9266 fn a_review_ref_reflog_only_commit_prevents_deletion() {
9267 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9268 let local_ref = review_ref(921);
9269 let original = test_git(&path, &["rev-parse", &local_ref]);
9270 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9271 let recovery = test_git(
9272 &path,
9273 &[
9274 "commit-tree",
9275 tree.trim(),
9276 "-p",
9277 original.trim(),
9278 "-m",
9279 "review ref recovery",
9280 ],
9281 );
9282 test_git(
9283 &path,
9284 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9285 );
9286 test_git(
9287 &path,
9288 &["update-ref", &local_ref, original.trim(), recovery.trim()],
9289 );
9290
9291 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9292 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9293 test_git(
9294 &path,
9295 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9296 );
9297 }
9298
9299 #[test]
9300 fn an_unpublished_commit_message_draft_is_recoverable() {
9301 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9302 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9303 let git_dir = if raw.is_absolute() {
9304 raw
9305 } else {
9306 path.join(raw)
9307 };
9308 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9309
9310 assert!(repository_has_recoverable_work(&path, true).unwrap());
9311 assert_eq!(
9312 "unique recovery draft\n",
9313 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9314 );
9315 }
9316
9317 #[test]
9318 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9319 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9320 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9321
9322 let error = repo
9323 .release_review_worktree_checked(902, &checkpoint)
9324 .unwrap_err();
9325
9326 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9327 assert!(error.to_string().contains("kept for recovery"), "{error}");
9328 assert_eq!(
9329 "recover me\n",
9330 std::fs::read_to_string(path.join("README.md")).unwrap()
9331 );
9332 repo.release_review_worktree(902);
9333 }
9334
9335 #[test]
9336 fn a_review_commit_is_retained_after_a_checked_read() {
9337 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9338 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9339 test_git(&path, &["add", "review-note.txt"]);
9340 test_git(&path, &["commit", "-m", "local review recovery"]);
9341 let head = test_git(&path, &["rev-parse", "HEAD"]);
9342
9343 let error = repo
9344 .release_review_worktree_checked(903, &checkpoint)
9345 .unwrap_err();
9346
9347 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9348 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9349 assert_eq!(
9350 "recover me\n",
9351 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9352 );
9353 repo.release_review_worktree(903);
9354 }
9355
9356 #[test]
9360 fn an_ignored_review_file_is_retained_after_a_checked_read() {
9361 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9362 std::fs::create_dir_all(path.join("generated")).unwrap();
9363 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9364
9365 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9366 .expect("its own output is not a change");
9367 let error = repo
9368 .release_review_worktree_checked(904, &checkpoint)
9369 .unwrap_err();
9370
9371 assert!(error.to_string().contains("recoverable"), "{error}");
9372 assert_eq!(
9373 "recover me\n",
9374 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9375 );
9376 repo.release_review_worktree(904);
9377 }
9378
9379 #[test]
9380 fn a_preexisting_ignored_review_file_change_is_retained() {
9386 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9387 std::fs::create_dir_all(path.join("generated")).unwrap();
9388 let ignored = path.join("generated/recovery.txt");
9389 std::fs::write(&ignored, "before\n").unwrap();
9390 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9391 std::fs::write(&ignored, "after!\n").unwrap();
9392
9393 let error = repo
9394 .release_review_worktree_checked(905, &checkpoint)
9395 .unwrap_err();
9396
9397 assert!(error.to_string().contains("recoverable"), "{error}");
9398 assert!(path.exists());
9399 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9400 repo.release_review_worktree(905);
9401 }
9402
9403 #[test]
9404 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9405 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9406 std::fs::create_dir_all(path.join("generated")).unwrap();
9407 let ignored = path.join("generated/recovery.txt");
9408 std::fs::write(&ignored, "keep me\n").unwrap();
9409 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9410
9411 let error = repo
9412 .release_review_worktree_checked(906, &checkpoint)
9413 .unwrap_err();
9414
9415 assert!(error.to_string().contains("recoverable"), "{error}");
9416 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9417 }
9418
9419 #[test]
9420 fn overwriting_a_preexisting_untracked_file_is_detected() {
9421 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9422 let untracked = path.join("notes.txt");
9423 std::fs::write(&untracked, "before\n").unwrap();
9424 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9425 std::fs::write(&untracked, "after!\n").unwrap();
9426
9427 let error = repo
9428 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9429 .unwrap_err();
9430
9431 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9432 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9433 }
9434
9435 #[test]
9436 fn an_assume_unchanged_edit_is_detected() {
9437 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9438 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9439 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9440
9441 let error = repo
9442 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9443 .unwrap_err();
9444
9445 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9446 assert_eq!(
9447 "hidden\n",
9448 std::fs::read_to_string(path.join("README.md")).unwrap()
9449 );
9450 }
9451
9452 #[test]
9453 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9454 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9455 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9456 test_git(&path, &["add", "README.md"]);
9457 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9458
9459 let error = repo
9460 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9461 .unwrap_err();
9462
9463 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9464 assert_eq!(
9465 b"seed\r\n",
9466 std::fs::read(path.join("README.md")).unwrap().as_slice()
9467 );
9468 }
9469
9470 #[cfg(unix)]
9471 #[test]
9472 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9473 use std::os::unix::fs::PermissionsExt;
9474
9475 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9476 test_git(&path, &["config", "core.filemode", "false"]);
9477 let readme = path.join("README.md");
9478 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9479 permissions.set_mode(0o755);
9480 std::fs::set_permissions(&readme, permissions).unwrap();
9481 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9482
9483 let error = repo
9484 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9485 .unwrap_err();
9486
9487 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9488 assert_eq!(
9489 0o755,
9490 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9491 );
9492 }
9493
9494 #[test]
9495 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9496 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9497 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9498 test_git(&path, &["add", ".gitattributes"]);
9499 test_git(&path, &["commit", "-m", "select data filter"]);
9500 let baseline = repo.worktree_baseline(&path).unwrap();
9501 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9502
9503 assert!(repo
9504 .commit_pending_changes(&path, &baseline, "change data", "change data")
9505 .unwrap());
9506 let error = repo
9507 .refuse_unrepresented_tracked_changes(&path, &baseline)
9508 .unwrap_err();
9509
9510 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9511 assert_eq!(
9512 "secret: recover me\nnew\n",
9513 std::fs::read_to_string(path.join("data.txt")).unwrap()
9514 );
9515 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9516 }
9517
9518 #[test]
9519 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9520 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9521 std::fs::create_dir_all(path.join("target")).unwrap();
9522 let untracked = path.join("target/user.yaml");
9523 std::fs::write(&untracked, "user data\n").unwrap();
9524 let baseline = repo.worktree_baseline(&path).unwrap();
9525 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9526
9527 assert!(repo
9528 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9529 .unwrap());
9530
9531 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9532 assert_eq!(
9533 "?? target/user.yaml\n",
9534 test_git(&path, &["status", "--short", "--untracked-files=all"])
9535 );
9536 assert!(test_git(
9537 &path,
9538 &[
9539 "ls-tree",
9540 "-r",
9541 "--name-only",
9542 "HEAD",
9543 "--",
9544 "target/user.yaml"
9545 ]
9546 )
9547 .is_empty());
9548 }
9549
9550 #[test]
9551 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9552 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9553 std::fs::create_dir_all(path.join("target")).unwrap();
9554 let untracked = path.join("target/user.yaml");
9555 std::fs::write(&untracked, "before\n").unwrap();
9556 let baseline = repo.worktree_baseline(&path).unwrap();
9557 let before = test_git(&path, &["rev-parse", "HEAD"]);
9558 std::fs::write(&untracked, "after\n").unwrap();
9559 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9560
9561 let error = repo
9562 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9563 .unwrap_err();
9564
9565 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9566 assert!(error.to_string().contains("target/user.yaml"), "{error}");
9567 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9568 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9569 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9570 }
9571
9572 #[test]
9573 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9574 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9575 let baseline = repo.worktree_baseline(&path).unwrap();
9576 std::fs::create_dir_all(path.join("target")).unwrap();
9577 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9578
9579 assert!(repo
9580 .commit_pending_changes(&path, &baseline, "add file", "add file")
9581 .unwrap());
9582
9583 assert_eq!(
9584 "new file\n",
9585 test_git(&path, &["show", "HEAD:target/new.txt"])
9586 );
9587 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9588 }
9589
9590 #[test]
9591 fn a_managed_commit_accepts_a_custom_output_rebuild() {
9592 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9593 exclude_paths(&repo, &["manager/public/"]);
9594 let output = path.join("manager/public/assets");
9595 std::fs::create_dir_all(&output).unwrap();
9596 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9597 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9598 let baseline = repo.worktree_baseline(&path).unwrap();
9599 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9600 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9601 std::fs::remove_file(output.join("old.js")).unwrap();
9602 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9603 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9604
9605 assert!(repo
9606 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9607 .unwrap());
9608 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9609 repo.refuse_changed_existing_untracked(&path, &baseline)
9610 .unwrap();
9611 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9612 .unwrap();
9613
9614 assert_eq!(
9615 "tracked change\n",
9616 test_git(&path, &["show", "HEAD:README.md"])
9617 );
9618 assert_eq!(
9619 "new source\n",
9620 test_git(&path, &["show", "HEAD:new-source.txt"])
9621 );
9622 assert!(test_git(
9623 &path,
9624 &[
9625 "ls-tree",
9626 "-r",
9627 "--name-only",
9628 "HEAD",
9629 "--",
9630 "manager/public"
9631 ]
9632 )
9633 .is_empty());
9634 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9635 assert!(!output.join("old.js").exists());
9636 assert_eq!(
9637 "new styles\n",
9638 std::fs::read_to_string(output.join("index.css")).unwrap()
9639 );
9640 assert!(repository_has_recoverable_work(&path, true).unwrap());
9641 repo.release_review_worktree(912);
9642 assert!(output.join("index-DUym-Rfj.js").exists());
9643 }
9644
9645 #[test]
9646 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9647 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9648 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9649 let baseline = repo.worktree_baseline(&path).unwrap();
9650 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9651 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9652
9653 let error = repo
9654 .commit_pending_changes(&path, &baseline, "change", "change")
9655 .unwrap_err();
9656
9657 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9658 assert!(error.to_string().contains("notes.txt"), "{error}");
9659 }
9660
9661 #[test]
9662 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9663 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9664 let baseline = repo.worktree_baseline(&path).unwrap();
9665 let nested = path.join("generated/repro");
9666 std::fs::create_dir_all(&nested).unwrap();
9667 test_git(&nested, &["init"]);
9668 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9669
9670 assert!(repo
9671 .commit_pending_changes(&path, &baseline, "change", "change")
9672 .unwrap());
9673 assert!(!repo.has_uncommitted_changes(&path).unwrap());
9674 assert!(repository_has_recoverable_work(&path, true).unwrap());
9675 repo.release_review_worktree(927);
9676 assert!(nested.join(".git").exists());
9677 }
9678
9679 #[test]
9680 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9681 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9682 test_git(&path, &["config", "core.autocrlf", "true"]);
9683 std::fs::write(path.join("README.md"), "seed\n").unwrap();
9684 assert_eq!(
9685 test_git(&path, &["hash-object", "README.md"]).trim(),
9686 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9687 );
9688
9689 assert!(repository_has_recoverable_work(&path, true).unwrap());
9690 assert_eq!(
9691 "seed\n",
9692 std::fs::read_to_string(path.join("README.md")).unwrap()
9693 );
9694 }
9695
9696 #[test]
9697 fn autocrlf_input_overrides_a_crlf_core_eol() {
9698 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9699 test_git(&path, &["config", "core.autocrlf", "input"]);
9700 test_git(&path, &["config", "core.eol", "crlf"]);
9701 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9702 assert_eq!(
9703 test_git(&path, &["hash-object", "README.md"]).trim(),
9704 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9705 );
9706
9707 assert!(repository_has_recoverable_work(&path, true).unwrap());
9708 assert_eq!(
9709 b"seed\r\n",
9710 std::fs::read(path.join("README.md")).unwrap().as_slice()
9711 );
9712 }
9713
9714 #[cfg(unix)]
9715 #[test]
9716 fn a_non_executable_permission_change_is_recoverable() {
9717 use std::os::unix::fs::PermissionsExt;
9718
9719 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9720 let readme = path.join("README.md");
9721 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9722 permissions.set_mode(0o600);
9723 std::fs::set_permissions(&readme, permissions).unwrap();
9724 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9725
9726 let error = repo
9727 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9728 .unwrap_err();
9729
9730 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9731 assert!(repository_has_recoverable_work(&path, true).unwrap());
9732 assert_eq!(
9733 0o600,
9734 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9735 );
9736 }
9737
9738 #[cfg(unix)]
9739 #[test]
9740 fn a_managed_commit_skips_signing_and_hooks() {
9741 use std::os::unix::fs::PermissionsExt;
9742
9743 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9744 let common = common_git_dir(&path).unwrap();
9745 let hook = common.join("hooks/pre-commit");
9746 let marker = fixture.root.join("hook-ran");
9747 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9748 std::fs::write(
9749 &hook,
9750 format!(
9751 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9752 sh_quote(marker.to_str().unwrap())
9753 ),
9754 )
9755 .unwrap();
9756 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9757 permissions.set_mode(0o755);
9758 std::fs::set_permissions(&hook, permissions).unwrap();
9759 test_git(&path, &["config", "commit.gpgsign", "true"]);
9760 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9761 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9762 test_git(&path, &["add", "managed.txt"]);
9763
9764 repo.commit_staged_changes(&path, "record managed change")
9765 .unwrap();
9766
9767 assert!(!marker.exists());
9768 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9769 }
9770
9771 #[test]
9772 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9773 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9774 std::fs::write(
9775 path.join(".gitattributes"),
9776 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
9777 )
9778 .unwrap();
9779 test_git(&path, &["add", ".gitattributes"]);
9780 test_git(&path, &["commit", "-m", "select automatic text"]);
9781 test_git(&path, &["config", "core.autocrlf", "true"]);
9782
9783 assert!(repository_has_recoverable_work(&path, true).unwrap());
9784 }
9785
9786 #[test]
9787 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
9788 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
9789 std::fs::write(
9790 path.join(".gitattributes"),
9791 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
9792 )
9793 .unwrap();
9794 test_git(&path, &["add", ".gitattributes"]);
9795 test_git(&path, &["commit", "-m", "select ident expansion"]);
9796 std::fs::write(path.join("README.md"), "seed\n").unwrap();
9797
9798 assert!(repository_has_recoverable_work(&path, true).unwrap());
9799 }
9800
9801 #[test]
9807 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
9808 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
9809 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
9810 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9811 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
9812 std::fs::write(
9813 path.join("bindings/uniffi-bindgen/Cargo.lock"),
9814 "# generated\n",
9815 )
9816 .unwrap();
9817
9818 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9819 .expect("a path the project ignores is the call's own output");
9820 }
9821
9822 #[test]
9823 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
9824 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
9825 exclude_paths(&repo, &["target/"]);
9826 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9827 std::fs::create_dir_all(path.join("target/package")).unwrap();
9828 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
9829
9830 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9831 .expect("an ignored attribute file governs only ignored paths");
9832 assert!(
9833 !repo.has_uncommitted_changes(&path).unwrap(),
9834 "ignored dependency attributes must not stop the next review"
9835 );
9836 }
9837
9838 #[test]
9839 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
9840 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
9841 std::fs::create_dir_all(path.join("source")).unwrap();
9842 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
9843
9844 assert!(repo.has_uncommitted_changes(&path).unwrap());
9845 }
9846
9847 #[test]
9848 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
9849 for source in ["repository", "nested", "local", "global"] {
9850 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
9851 std::fs::create_dir_all(path.join("manager")).unwrap();
9852 match source {
9853 "repository" => {
9854 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
9855 .unwrap();
9856 test_git(&path, &["add", ".gitignore"]);
9857 test_git(&path, &["commit", "-m", "ignore artifacts"]);
9858 }
9859 "nested" => {
9860 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
9861 test_git(&path, &["add", "manager/.gitignore"]);
9862 test_git(&path, &["commit", "-m", "ignore artifacts"]);
9863 }
9864 "local" => exclude_paths(&repo, &["manager/scratch/"]),
9865 "global" => {
9866 let excludes = repo.root().parent().unwrap().join("global-ignore");
9867 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
9868 test_git(
9869 &path,
9870 &["config", "core.excludesFile", excludes.to_str().unwrap()],
9871 );
9872 }
9873 _ => unreachable!(),
9874 }
9875 let output = path.join("manager/scratch");
9876 std::fs::create_dir_all(&output).unwrap();
9877 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
9878 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
9879 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9880 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
9881 std::fs::remove_file(output.join(".DS_Store")).unwrap();
9882 for file in [
9883 ".gitattributes",
9884 "Cargo.lock",
9885 "font.woff2",
9886 "bundle.js",
9887 "cache.db",
9888 ] {
9889 std::fs::write(output.join(file), "generated\n").unwrap();
9890 }
9891 #[cfg(unix)]
9892 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
9893 let nested = output.join("dependency");
9894 std::fs::create_dir_all(&nested).unwrap();
9895 test_git(&nested, &["init"]);
9896 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9897 .unwrap_or_else(|error| panic!("{source}: {error}"));
9898 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9899
9900 let baseline = repo.worktree_baseline(&path).unwrap();
9901 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
9902 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
9903 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
9904 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
9905 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
9906 assert!(repo
9907 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
9908 .unwrap_or_else(|error| panic!("{source}: {error}")));
9909 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9910 assert!(test_git(
9911 &path,
9912 &[
9913 "ls-tree",
9914 "-r",
9915 "--name-only",
9916 "HEAD",
9917 "--",
9918 "manager/scratch"
9919 ]
9920 )
9921 .is_empty());
9922 assert!(
9923 repository_has_recoverable_work(&path, true).unwrap(),
9924 "{source}"
9925 );
9926 repo.release_review_worktree(945);
9927 assert_eq!(
9928 "LOCAL=updated\n",
9929 std::fs::read_to_string(output.join(".env.local")).unwrap()
9930 );
9931 assert!(nested.join(".git").exists(), "{source}");
9932 }
9933 }
9934
9935 #[test]
9939 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
9940 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
9941 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9942 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
9943
9944 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9945 .expect("spar's own marker is not something a call did");
9946 }
9947
9948 #[test]
9949 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
9950 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
9951
9952 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9953 .expect("a path a build removed is not an unreadable worktree");
9954
9955 assert_eq!(vanished_untracked_file(), gone);
9956 }
9957
9958 #[test]
9961 fn a_vanished_path_is_not_recorded_as_absent() {
9962 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9963 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9964 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9965 std::fs::remove_file(path.join("notes.txt")).unwrap();
9966
9967 let error = repo
9968 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9969 .unwrap_err();
9970
9971 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9972 }
9973
9974 fn exclude_paths(repo: &Repo, lines: &[&str]) {
9975 use std::io::Write;
9976 let path = repo.root().join(".git").join("info").join("exclude");
9977 let mut file = std::fs::OpenOptions::new()
9978 .create(true)
9979 .append(true)
9980 .open(&path)
9981 .unwrap();
9982 for line in lines {
9983 writeln!(file, "{line}").unwrap();
9984 }
9985 }
9986
9987 #[test]
9988 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9989 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9990 exclude_paths(&repo, &["dist/"]);
9991 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9992 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9993 let baseline = repo.worktree_baseline(&path).unwrap();
9994 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9995 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9996 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9997 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9998
9999 assert!(repo
10000 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10001 .unwrap());
10002 assert_eq!(
10003 "tracked change\n",
10004 std::fs::read_to_string(path.join("README.md")).unwrap()
10005 );
10006 }
10007
10008 #[test]
10009 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10010 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10011 exclude_paths(&repo, &["dist/"]);
10012 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10013 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10014 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10015 let baseline = repo.worktree_baseline(&path).unwrap();
10016 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10017 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10018 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10019 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10020
10021 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10022 repo.refuse_changed_existing_untracked(&path, &baseline)
10023 .unwrap();
10024 }
10025
10026 #[test]
10027 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10028 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10029 exclude_paths(&repo, &["dist/"]);
10030 std::fs::create_dir_all(path.join("dist")).unwrap();
10031 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10032 let baseline = repo.worktree_baseline(&path).unwrap();
10033 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10034 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10035
10036 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10037 repo.refuse_changed_existing_untracked(&path, &baseline)
10038 .unwrap();
10039 }
10040
10041 #[test]
10050 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10051 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10052 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10053 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10054 test_git(
10055 repo.root(),
10056 &[
10057 "worktree",
10058 "add",
10059 "-b",
10060 "issue-950",
10061 held.to_str().unwrap(),
10062 "main",
10063 ],
10064 );
10065
10066 let found = repo
10067 .worktree_holding("issue-950")
10068 .expect("the branch is checked out somewhere");
10069
10070 assert!(found.ends_with("issue-950"), "{}", found.display());
10071 assert_eq!(None, repo.worktree_holding("issue-951"));
10072 }
10073
10074 #[test]
10075 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10076 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10077 exclude_paths(&repo, &["manager/public/assets/"]);
10078 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10079 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10080 std::fs::write(&bundle, "first build\n").unwrap();
10081 let baseline = repo.worktree_baseline(&path).unwrap();
10082 std::fs::write(&bundle, "second build\n").unwrap();
10083 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10084
10085 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10086 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10087 }
10088
10089 #[test]
10090 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10091 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10092 exclude_paths(&repo, &["dist/"]);
10093 let baseline = repo.worktree_baseline(&path).unwrap();
10094 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10095
10096 let error = repo
10097 .refuse_read_only_leavings(&path, &baseline)
10098 .unwrap_err();
10099
10100 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10101 assert!(error.to_string().contains("scratch.md"), "{error}");
10102 }
10103
10104 #[test]
10105 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10106 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10107 exclude_paths(&repo, &["dist/", ".env.local"]);
10108 std::fs::create_dir_all(path.join("dist")).unwrap();
10109 let baseline = repo.worktree_baseline(&path).unwrap();
10110 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10111 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10112
10113 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10114 assert!(repository_has_recoverable_work(&path, true).unwrap());
10115 }
10116
10117 #[test]
10118 fn a_read_only_inspection_may_rebuild_generated_output() {
10119 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10120 exclude_paths(&repo, &["dist/"]);
10121 std::fs::create_dir_all(path.join("dist")).unwrap();
10122 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10123 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10124 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10125 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10126
10127 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10128 .unwrap();
10129 }
10130
10131 #[test]
10132 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10133 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10134 exclude_paths(&repo, &["dist/", ".env.local"]);
10135 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10136 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10137 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10138
10139 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10140 .unwrap();
10141 }
10142
10143 #[test]
10144 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10145 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10146 exclude_paths(&repo, &["dist/", ".env.local"]);
10147 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10148 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10149 std::fs::remove_file(path.join(".env.local")).unwrap();
10150
10151 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10152 .unwrap();
10153 }
10154
10155 #[test]
10156 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10157 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10158 exclude_paths(&repo, &["dist/"]);
10159 std::fs::write(path.join("notes.md"), "before\n").unwrap();
10160 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10161 std::fs::write(path.join("notes.md"), "after\n").unwrap();
10162
10163 let error = repo
10164 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10165 .unwrap_err();
10166
10167 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10168 assert!(error.to_string().contains("notes.md"), "{error}");
10169 }
10170
10171 #[test]
10172 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10173 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10174 exclude_paths(&repo, &["dist/", "local.env"]);
10175 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10176 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10177 std::fs::write(
10180 repo.root().join(".git").join("info").join("exclude"),
10181 "dist/\n",
10182 )
10183 .unwrap();
10184
10185 let error = repo
10186 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10187 .unwrap_err();
10188
10189 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10190 assert!(error.to_string().contains("local.env"), "{error}");
10191 }
10192
10193 #[test]
10194 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10195 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10196 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10197 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10198
10199 let error = repo
10200 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10201 .unwrap_err();
10202
10203 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10204 assert!(error.to_string().contains("README.md"), "{error}");
10205 }
10206
10207 #[test]
10208 fn build_output_alone_does_not_keep_a_worktree() {
10209 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10210 exclude_paths(&repo, &["target/", "dist/"]);
10211 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10212 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10213 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10214 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10215
10216 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10217 repo.release_review_worktree(933);
10218
10219 assert!(!path.exists());
10220 }
10221
10222 #[test]
10223 fn finder_metadata_alone_does_not_keep_a_worktree() {
10224 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10225 exclude_paths(&repo, &[".DS_Store"]);
10226 std::fs::create_dir_all(path.join("src")).unwrap();
10227 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10228 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10229
10230 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10231 repo.release_review_worktree(937);
10232
10233 assert!(!path.exists());
10234 }
10235
10236 #[test]
10237 fn a_fetch_record_alone_does_not_keep_a_worktree() {
10238 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10239 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10240 let git_dir = if raw.is_absolute() {
10241 raw
10242 } else {
10243 path.join(raw)
10244 };
10245 let head = test_git(&path, &["rev-parse", "HEAD"]);
10246 std::fs::write(
10247 git_dir.join("FETCH_HEAD"),
10248 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10249 )
10250 .unwrap();
10251
10252 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10253 repo.release_review_worktree(938);
10254
10255 assert!(!path.exists());
10256 }
10257
10258 #[test]
10259 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10260 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10261 exclude_paths(&repo, &["target/", ".env.local"]);
10262 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10263 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10264 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10265
10266 assert!(repository_has_recoverable_work(&path, true).unwrap());
10267 repo.release_review_worktree(934);
10268
10269 assert_eq!(
10270 "TOKEN=keep me\n",
10271 std::fs::read_to_string(path.join(".env.local")).unwrap()
10272 );
10273 }
10274
10275 #[test]
10276 fn a_repository_nested_in_build_output_keeps_a_worktree() {
10277 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10278 exclude_paths(&repo, &["node_modules/"]);
10279 let nested = path.join("node_modules/local-dep");
10280 std::fs::create_dir_all(&nested).unwrap();
10281 test_git(&nested, &["init"]);
10282 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10283
10284 assert!(repository_has_recoverable_work(&path, true).unwrap());
10285 repo.release_review_worktree(935);
10286
10287 assert!(nested.join(".git").exists());
10288 }
10289
10290 #[test]
10291 fn an_ordinary_untracked_file_keeps_a_worktree() {
10292 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10293 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10294
10295 assert!(repository_has_recoverable_work(&path, true).unwrap());
10296 repo.release_review_worktree(936);
10297
10298 assert_eq!(
10299 "somebody's notes\n",
10300 std::fs::read_to_string(path.join("notes.md")).unwrap()
10301 );
10302 }
10303
10304 #[test]
10305 fn a_legacy_crlf_checkout_is_retained_conservatively() {
10306 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10307 std::fs::write(
10308 path.join(".gitattributes"),
10309 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10310 )
10311 .unwrap();
10312 test_git(&path, &["add", ".gitattributes"]);
10313 test_git(&path, &["commit", "-m", "select legacy line endings"]);
10314
10315 assert!(repository_has_recoverable_work(&path, true).unwrap());
10316 }
10317
10318 #[test]
10319 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10320 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10321 let nested = path.join("tracked");
10322 std::fs::create_dir_all(&nested).unwrap();
10323 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10324 test_git(&path, &["add", "tracked/seed.txt"]);
10325 test_git(&path, &["commit", "-m", "add tracked directory"]);
10326 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10327 test_git(&nested, &["init"]);
10328
10329 let error = repo
10330 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10331 .unwrap_err();
10332
10333 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10334 assert!(error.to_string().contains("Git entry"), "{error}");
10335 assert!(nested.join(".git").exists());
10336 }
10337
10338 #[test]
10343 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10344 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10345 exclude_paths(&repo, &["target/"]);
10346 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10347 let repro = path.join("target/pr918-repro");
10348 std::fs::create_dir_all(&repro).unwrap();
10349 test_git(&repro, &["init"]);
10350 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10351
10352 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10353 .unwrap();
10354
10355 assert!(repository_has_recoverable_work(&path, true).unwrap());
10356 assert!(repro.join(".git").exists());
10357 }
10358
10359 #[test]
10363 fn a_repository_inside_a_nested_checkout_is_left_alone() {
10364 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10365 exclude_paths(&repo, &["target/"]);
10366 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10367 let repro = path.join("target/repro");
10368 let vendored = repro.join("vendor/dep");
10369 std::fs::create_dir_all(&vendored).unwrap();
10370 test_git(&repro, &["init"]);
10371 test_git(&vendored, &["init"]);
10372
10373 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10374 .unwrap();
10375
10376 assert!(vendored.join(".git").exists());
10377 }
10378
10379 #[test]
10380 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10381 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10382
10383 let state = ignored_untracked_state(repo.root()).unwrap();
10384
10385 let relative = path.strip_prefix(repo.root()).unwrap();
10386 assert!(
10387 state.files.contains_key(relative),
10388 "{:?}",
10389 state.files.keys().collect::<Vec<_>>()
10390 );
10391 assert!(state.is_ignored(relative));
10392 }
10393
10394 #[test]
10395 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10396 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10397 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10398 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10399 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10400
10401 repo.refuse_new_ignored_files(repo.root(), &baseline)
10402 .unwrap();
10403 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10404 .unwrap();
10405 }
10406
10407 #[test]
10408 fn deleting_a_resident_worktree_during_a_call_is_refused() {
10409 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10410 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10411 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10412 std::fs::remove_dir_all(&path).unwrap();
10413
10414 let read_error = repo
10415 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10416 .unwrap_err();
10417 assert!(
10418 read_error.to_string().contains("review-932"),
10419 "{read_error}"
10420 );
10421 let error = repo
10422 .refuse_new_ignored_files(repo.root(), &baseline)
10423 .unwrap_err();
10424
10425 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10426 assert!(error.to_string().contains("review-932"), "{error}");
10427 }
10428
10429 #[test]
10430 fn a_nested_repository_record_is_read_as_a_plain_path() {
10431 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10432 assert_eq!(Path::new("vendor/checkout"), path);
10433 assert!(nested);
10434
10435 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10436 assert_eq!(Path::new("vendor/notes.txt"), path);
10437 assert!(!nested);
10438
10439 assert!(untracked_record(b"/", "untracked").is_err());
10440 }
10441
10442 #[cfg(unix)]
10443 #[test]
10444 fn a_non_utf8_git_path_is_preserved_without_loss() {
10445 use std::os::unix::ffi::OsStrExt;
10446
10447 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10448
10449 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10450 }
10451
10452 #[test]
10453 fn guarded_merge_pins_the_reviewed_head() {
10454 let args = merge_pr_args("36", Some("abc123"), true);
10455 assert_eq!(
10456 vec![
10457 "pr",
10458 "merge",
10459 "36",
10460 "--squash",
10461 "--delete-branch",
10462 "--match-head-commit",
10463 "abc123"
10464 ],
10465 args
10466 );
10467 }
10468
10469 #[test]
10470 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10471 let pr = PrRef {
10472 number: 7,
10473 url: "https://example.test/pull/7".into(),
10474 title: "part one".into(),
10475 };
10476 let result = reconcile_pr_creation(
10477 "split-34-1",
10478 Err(crate::error::SparError::new("connection lost")),
10479 Ok(Some(pr)),
10480 )
10481 .unwrap();
10482 assert_eq!(7, result.number);
10483 }
10484
10485 #[test]
10486 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10487 let error = reconcile_pr_creation(
10488 "split-34-1",
10489 Err(crate::error::SparError::new("permission denied")),
10490 Ok(None),
10491 )
10492 .unwrap_err();
10493 assert!(error.to_string().contains("permission denied"), "{error}");
10494 }
10495
10496 #[test]
10497 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10498 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10499 assert!(pr_for_base(text, "split-34-2", "split-34-1")
10500 .unwrap()
10501 .is_none());
10502 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10503 assert_eq!(7, found.number);
10504 }
10505
10506 #[test]
10507 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10508 let result = reconcile_comment_post(
10509 34,
10510 "the summary",
10511 crate::error::SparError::new("connection lost"),
10512 Ok(vec![serde_json::json!({"body": "the summary"})]),
10513 );
10514 assert!(result.is_ok(), "{result:?}");
10515 }
10516
10517 #[test]
10518 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10519 let error = reconcile_comment_post(
10520 34,
10521 "the summary",
10522 crate::error::SparError::new("connection lost"),
10523 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10524 )
10525 .unwrap_err();
10526 assert_eq!("connection lost", error.to_string());
10527 }
10528
10529 #[test]
10530 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10531 let error = reconcile_comment_post(
10532 34,
10533 "the summary",
10534 crate::error::SparError::new("connection lost"),
10535 Err(crate::error::SparError::new("comments unavailable")),
10536 )
10537 .unwrap_err();
10538 assert!(
10539 error.to_string().contains("could not be verified"),
10540 "{error}"
10541 );
10542 assert!(
10543 error.to_string().contains("comments unavailable"),
10544 "{error}"
10545 );
10546 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10547 assert!(!error.worth_retrying());
10548 }
10549
10550 #[test]
10551 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10552 let result = reconcile_issue_edit(
10553 34,
10554 "wanted body",
10555 crate::error::SparError::new("connection lost"),
10556 Ok("wanted body".to_string()),
10557 );
10558 assert!(result.is_ok(), "{result:?}");
10559 }
10560
10561 #[test]
10562 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10563 let error = reconcile_issue_edit(
10564 34,
10565 "wanted body",
10566 crate::error::SparError::new("connection lost"),
10567 Err(crate::error::SparError::new("issue unavailable")),
10568 )
10569 .unwrap_err();
10570 assert!(
10571 error.to_string().contains("could not be verified"),
10572 "{error}"
10573 );
10574 assert!(error.to_string().contains("issue unavailable"), "{error}");
10575 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10576 assert!(!error.worth_retrying());
10577 }
10578
10579 #[test]
10580 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10581 let found = ExistingIssue {
10582 number: 101,
10583 url: "https://example.test/issues/101".into(),
10584 title: "child".into(),
10585 body: "body".into(),
10586 open: true,
10587 };
10588 let url = reconcile_issue_creation(
10589 "child",
10590 Err(crate::error::SparError::new("connection lost")),
10591 Ok(Some(found)),
10592 )
10593 .unwrap();
10594 assert_eq!("https://example.test/issues/101", url);
10595 }
10596
10597 #[test]
10598 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10599 let error = reconcile_issue_creation(
10600 "child",
10601 Err(crate::error::SparError::new("permission denied")),
10602 Ok(None),
10603 )
10604 .unwrap_err();
10605 assert!(error.to_string().contains("permission denied"), "{error}");
10606 }
10607
10608 #[test]
10609 fn an_unverifiable_issue_creation_is_marked_uncertain() {
10610 let error = reconcile_issue_creation(
10611 "child",
10612 Err(crate::error::SparError::new("connection lost")),
10613 Err(crate::error::SparError::new("issues unavailable")),
10614 )
10615 .unwrap_err();
10616 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10617 assert!(!error.worth_retrying());
10618 }
10619
10620 #[test]
10621 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10622 let result = reconcile_failed_split_push(
10623 "split-34-1",
10624 crate::error::SparError::new("connection lost"),
10625 Ok("abc123\n".into()),
10626 Ok("abc123\trefs/heads/split-34-1\n".into()),
10627 );
10628 assert!(result.is_ok(), "{result:?}");
10629 }
10630
10631 #[test]
10632 fn a_split_push_collision_is_definite_and_never_overwrites() {
10633 let error = reconcile_failed_split_push(
10634 "split-34-1",
10635 crate::error::SparError::new("lease rejected"),
10636 Ok("abc123\n".into()),
10637 Ok("def456\trefs/heads/split-34-1\n".into()),
10638 )
10639 .unwrap_err();
10640 assert!(!error.retain_worktree());
10641 assert!(
10642 error.to_string().contains("Nothing was overwritten"),
10643 "{error}"
10644 );
10645 }
10646
10647 #[test]
10648 fn an_unreadable_split_push_result_keeps_the_worktree() {
10649 let error = reconcile_failed_split_push(
10650 "split-34-1",
10651 crate::error::SparError::new("connection lost"),
10652 Ok("abc123\n".into()),
10653 Err(crate::error::SparError::new("origin unavailable")),
10654 )
10655 .unwrap_err();
10656 assert!(error.retain_worktree());
10657 assert!(error.to_string().contains("could not confirm"), "{error}");
10658 }
10659
10660 #[test]
10664 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10665 let repo = repo_for_titles();
10666 for raw in [
10667 "Retry loop spins \u{2014} Retry-After parses to zero",
10668 "plain title",
10669 " spread over\nlines ",
10670 "\u{1F916} Generated with something",
10671 &format!("a \u{2014} {}", "very long title ".repeat(20)),
10672 &"x".repeat(300),
10673 &format!("{} \u{2014} end", "y".repeat(88)),
10674 &{
10679 let tail = "a\u{2014}b c\u{2014}d";
10680 let pad = Style::default().max_title_chars - tail.chars().count();
10681 format!("{}{tail}", "w".repeat(pad))
10682 },
10683 ] {
10684 let once = repo.clean_title(raw).unwrap();
10685 let twice = repo.clean_title(&once).unwrap();
10686 assert_eq!(once, twice, "not idempotent for {raw:?}");
10687 assert!(
10688 once.chars().count() <= repo.style.max_title_chars,
10689 "over budget: {once:?}"
10690 );
10691 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10692 }
10693 }
10694
10695 #[test]
10696 fn a_title_with_an_em_dash_survives_as_readable_text() {
10697 let repo = repo_for_titles();
10698 assert_eq!(
10699 "Retry loop spins, Retry-After parses to zero",
10700 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10701 .unwrap()
10702 );
10703 }
10704
10705 #[test]
10706 fn sh_quote_survives_a_quote() {
10707 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10708 }
10709
10710 #[test]
10711 fn sh_quote_wraps_a_space() {
10712 assert_eq!(
10713 "'/Applications/My App/spar'",
10714 sh_quote("/Applications/My App/spar")
10715 );
10716 }
10717
10718 #[test]
10719 fn finished_states_are_recognised_case_insensitively() {
10720 assert!(is_finished("MERGED"));
10721 assert!(is_finished("closed"));
10722 assert!(!is_finished("OPEN"));
10723 assert!(!is_finished(""));
10724 }
10725
10726 fn state() -> PersistedState {
10727 PersistedState {
10728 version: 1,
10729 checkpoint: 0,
10730 round: 4,
10731 next_actor: "codex".into(),
10732 status: Status::Pending,
10733 pr_head: "abc123".into(),
10734 ledger: Ledger::new(),
10735 filed: vec![],
10736 open_findings: vec![Finding {
10737 severity: Severity::Blocking,
10738 title: "Unchecked error".into(),
10739 detail: "the failure is discarded".into(),
10740 file: "src/a.rs:12".into(),
10741 ..Finding::default()
10742 }],
10743 disputes: vec![Dispute {
10744 title: "Retry limit".into(),
10745 file: "src/net.rs".into(),
10746 reasoning: "the caller already bounds it".into(),
10747 }],
10748 noted: vec![Finding {
10749 severity: Severity::NonBlocking,
10750 title: "Timeout is fixed".into(),
10751 file: "src/config.rs".into(),
10752 ..Finding::default()
10753 }],
10754 }
10755 }
10756
10757 #[test]
10758 fn a_state_comment_round_trips() {
10759 let body = format!(
10760 "{STATE_MARKER}\n{}\n-->",
10761 serde_json::to_string(&state()).unwrap()
10762 );
10763 let back = parse_state_comment(&body).unwrap();
10764 assert_eq!(4, back.round);
10765 assert_eq!("codex", back.next_actor);
10766 assert_eq!("abc123", back.pr_head);
10767 assert_eq!("Unchecked error", back.open_findings[0].title);
10768 assert_eq!("src/net.rs", back.disputes[0].file);
10769 assert_eq!("Timeout is fixed", back.noted[0].title);
10770 }
10771
10772 #[test]
10773 fn old_state_without_new_lists_still_parses() {
10774 let body = format!(
10775 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
10776 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10777 );
10778 let back = parse_state_comment(&body).expect("old state");
10779 assert!(back.open_findings.is_empty());
10780 assert!(back.disputes.is_empty());
10781 assert!(back.noted.is_empty());
10782 assert!(back.pr_head.is_empty());
10783 assert_eq!(0, back.checkpoint);
10784 }
10785
10786 #[test]
10787 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
10788 let mut local = state();
10789 local.pr_head = "old".into();
10790 local.round = 9;
10791 let mut remote = state();
10792 remote.pr_head = "current".into();
10793 remote.round = 4;
10794
10795 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10796 assert_eq!("current", chosen.pr_head);
10797 assert_eq!(4, chosen.round);
10798 }
10799
10800 #[test]
10801 fn checkpoint_order_breaks_same_round_ties() {
10802 let mut local = state();
10803 local.pr_head = "current".into();
10804 local.round = 4;
10805 local.checkpoint = 8;
10806 let mut remote = local.clone();
10807 remote.checkpoint = 7;
10808 remote.open_findings.clear();
10809
10810 let chosen = choose_state_for_head(vec![local], "current").unwrap();
10811 assert_eq!(8, chosen.checkpoint);
10812
10813 let mut local = state();
10814 local.pr_head = "current".into();
10815 local.round = 4;
10816 local.checkpoint = 8;
10817 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
10818 assert_eq!(8, chosen.checkpoint);
10819 }
10820
10821 #[test]
10822 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
10823 let mut local = state();
10824 local.pr_head = "current".into();
10825 local.round = 4;
10826 local.open_findings.push(Finding {
10827 title: "local checkpoint".into(),
10828 ..Finding::default()
10829 });
10830 let mut remote = state();
10831 remote.pr_head = "current".into();
10832 remote.round = 4;
10833
10834 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10835 assert_eq!(
10836 "local checkpoint",
10837 chosen.open_findings.last().unwrap().title
10838 );
10839 }
10840
10841 #[test]
10843 fn the_state_block_is_an_html_comment() {
10844 let body = format!(
10845 "{STATE_MARKER}\n{}\n-->",
10846 serde_json::to_string(&state()).unwrap()
10847 );
10848 assert!(body.starts_with("<!--"));
10849 assert!(body.trim_end().ends_with("-->"));
10850 assert!(!body[..body.find('{').unwrap()].contains("-->"));
10851 }
10852
10853 #[test]
10854 fn an_unrelated_json_block_is_not_state() {
10855 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
10856 }
10857
10858 #[test]
10865 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
10866 let block = format!(
10867 "{STATE_MARKER}\n{}\n-->",
10868 serde_json::to_string(&state()).unwrap()
10869 );
10870 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
10871 assert!(
10872 is_state_comment(&block, "Spar-Bot", "spar-bot"),
10873 "a login differs only in case"
10874 );
10875
10876 assert!(
10877 !is_state_comment(
10878 "why is there a `<!-- spar:state` block on this PR?",
10879 "maintainer",
10880 "spar-bot"
10881 ),
10882 "a question about the block was treated as the block"
10883 );
10884 assert!(
10885 !is_state_comment(&block, "maintainer", "spar-bot"),
10886 "somebody else's comment is not spar's to rewrite"
10887 );
10888 assert!(
10889 !is_state_comment(
10890 &format!("here is what spar writes:\n\n{block}"),
10891 "spar-bot",
10892 "spar-bot"
10893 ),
10894 "the marker has to open the body, not appear in it"
10895 );
10896 assert!(
10897 !is_state_comment(
10898 &format!("{STATE_MARKER}\n{{not json\n-->"),
10899 "spar-bot",
10900 "spar-bot"
10901 ),
10902 "a block that does not parse is not state"
10903 );
10904 assert!(
10905 !is_state_comment(&block, "spar-bot", ""),
10906 "an unknown viewer proves nothing about who wrote it"
10907 );
10908 }
10909
10910 #[test]
10911 fn a_malformed_state_comment_is_none_not_a_panic() {
10912 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
10913 }
10914
10915 #[test]
10916 fn atomic_write_leaves_no_temp_file() {
10917 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
10918 let _ = std::fs::remove_dir_all(&dir);
10919 let path = dir.join("state").join("pr-7.json");
10920 write_json_atomic(&path, &state()).unwrap();
10921 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
10922 .unwrap()
10923 .flatten()
10924 .filter_map(|e| e.file_name().to_str().map(str::to_string))
10925 .collect();
10926 assert_eq!(vec!["pr-7.json".to_string()], files);
10927 let _ = std::fs::remove_dir_all(&dir);
10928 }
10929
10930 #[test]
10931 fn atomic_write_overwrites_rather_than_accumulating() {
10932 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
10933 let _ = std::fs::remove_dir_all(&dir);
10934 let path = dir.join("pr-7.json");
10935 for round in 1..4 {
10936 let mut s = state();
10937 s.round = round;
10938 write_json_atomic(&path, &s).unwrap();
10939 }
10940 let back: PersistedState =
10941 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10942 assert_eq!(3, back.round);
10943 let _ = std::fs::remove_dir_all(&dir);
10944 }
10945
10946 #[test]
10947 fn style_from_env_defaults_to_enforcing() {
10948 std::env::remove_var("SPAR_BAN_EM_DASH");
10949 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
10950 let style = style_from_env();
10951 assert!(style.ban_em_dash && style.ban_ai_attribution);
10952 assert!(
10953 !style.terse,
10954 "the commit filter must not truncate a commit message"
10955 );
10956 }
10957
10958 #[test]
10963 fn a_push_denial_is_read_from_either_transport() {
10964 let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
10965 fatal: Could not read from remote repository.\n\n\
10966 Please make sure you have the correct access rights\n\
10967 and the repository exists.";
10968 let https = "remote: Permission to example/project.git denied to someone.\n\
10969 fatal: unable to access 'https://github.com/example/project.git/': The \
10970 requested URL returned error: 403";
10971 let denied = Some("Permission to example/project.git denied to someone.");
10972 assert_eq!(denied, push_denial(ssh));
10973 assert_eq!(denied, push_denial(https));
10974
10975 let stale = "To github.com:example/project.git\n \
10976 ! [rejected] HEAD -> issue-7 (stale info)\n\
10977 error: failed to push some refs to 'github.com:example/project.git'";
10978 assert_eq!(None, push_denial(stale));
10979
10980 let err = push_error(
10981 &PushTarget::Origin,
10982 "issue-7",
10983 &crate::error::SparError::new(ssh),
10984 );
10985 assert_eq!(
10986 "could not push to origin/issue-7. Permission to example/project.git denied to \
10987 someone.",
10988 err.to_string()
10989 );
10990 }
10991
10992 #[cfg(unix)]
10996 fn denied_fixture(
10997 tag: &str,
10998 fork_when_denied: bool,
10999 ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11000 use std::os::unix::fs::PermissionsExt;
11001 use std::sync::atomic::{AtomicU32, Ordering};
11002 static NEXT: AtomicU32 = AtomicU32::new(0);
11003 let id = NEXT.fetch_add(1, Ordering::Relaxed);
11004 let root =
11005 std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11006 let origin = root.join("origin.git");
11007 let fork = root.join("fork.git");
11008 let work = root.join("work");
11009 for dir in [&origin, &fork, &work] {
11010 std::fs::create_dir_all(dir).unwrap();
11011 }
11012 test_git(&origin, &["init", "--bare", "-b", "main"]);
11013 test_git(&fork, &["init", "--bare", "-b", "main"]);
11014 test_git(&work, &["init", "-b", "main"]);
11015 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11016 test_git(&work, &["config", "user.name", "spar test"]);
11017 test_git(&work, &["config", "commit.gpgsign", "false"]);
11018 std::fs::write(work.join("README.md"), "seed\n").unwrap();
11019 test_git(&work, &["add", "."]);
11020 test_git(&work, &["commit", "-m", "seed"]);
11021 test_git(
11022 &work,
11023 &["remote", "add", "origin", origin.to_str().unwrap()],
11024 );
11025 test_git(&work, &["push", "-q", "origin", "main"]);
11026 let hook = origin.join("hooks").join("pre-receive");
11028 std::fs::write(
11029 &hook,
11030 "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11031 )
11032 .unwrap();
11033 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11034 test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11035 std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11036 test_git(&work, &["add", "."]);
11037 test_git(&work, &["commit", "-m", "Fix it"]);
11038
11039 let mut text =
11040 String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11041 if !fork_when_denied {
11042 text.push_str("[loop]\nfork_when_denied = false\n");
11043 }
11044 let cfg = crate::config::parse(&text).unwrap();
11045 let repo = Repo::open(&work, &cfg).unwrap();
11046 repo.fork
11048 .set(Fork {
11049 owner: "someone".into(),
11050 full_name: "someone/project".into(),
11051 url: fork.to_str().unwrap().into(),
11052 })
11053 .unwrap();
11054 (ReviewFixture { root }, repo, work, origin, fork)
11055 }
11056
11057 #[cfg(unix)]
11058 #[test]
11059 fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11060 let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11061 let first = test_git(&work, &["rev-parse", "HEAD"]);
11062
11063 let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11064
11065 assert_eq!("someone:issue-7", target.head("issue-7"));
11066 assert_eq!(
11067 first.trim(),
11068 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11069 );
11070 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11071 .trim()
11072 .is_empty());
11073 assert_eq!(
11074 WriteSummary {
11075 attempted: 1,
11076 failed: 0
11077 },
11078 repo.write_summary(),
11079 "a refusal the fork answered is not a failed write"
11080 );
11081
11082 std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11085 test_git(&work, &["commit", "-qam", "Fix it again"]);
11086 repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11087 .unwrap();
11088 let second = test_git(&work, &["rev-parse", "HEAD"]);
11089 assert_eq!(
11090 second.trim(),
11091 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11092 );
11093 }
11094
11095 #[cfg(unix)]
11099 #[test]
11100 fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11101 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11102 test_git(
11103 &work,
11104 &[
11105 "push",
11106 "-q",
11107 fork.to_str().unwrap(),
11108 "main:refs/heads/issue-7",
11109 ],
11110 );
11111 let head = test_git(&work, &["rev-parse", "HEAD"]);
11112
11113 repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11114
11115 assert_eq!(
11116 head.trim(),
11117 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11118 );
11119 assert_eq!(0, repo.write_summary().failed);
11120 }
11121
11122 #[cfg(unix)]
11123 #[test]
11124 fn work_only_the_fork_has_is_not_published_over() {
11125 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11126 test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11127 std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11128 test_git(&work, &["add", "."]);
11129 test_git(&work, &["commit", "-qm", "Somebody's work"]);
11130 test_git(
11131 &work,
11132 &[
11133 "push",
11134 "-q",
11135 fork.to_str().unwrap(),
11136 "elsewhere:refs/heads/issue-7",
11137 ],
11138 );
11139 let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11140 test_git(&work, &["checkout", "-q", "issue-7"]);
11141
11142 let err = repo
11143 .publish_new_branch(&work, "issue-7", "main")
11144 .unwrap_err()
11145 .to_string();
11146
11147 assert!(
11148 err.contains(
11149 "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11150 ),
11151 "{err}"
11152 );
11153 assert!(err.contains("--delete issue-7"), "{err}");
11154 assert_eq!(
11155 theirs.trim(),
11156 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11157 );
11158 assert_eq!(
11159 WriteSummary {
11160 attempted: 1,
11161 failed: 1
11162 },
11163 repo.write_summary()
11164 );
11165 }
11166
11167 #[test]
11169 fn a_rejected_push_names_the_ref_it_rejected() {
11170 let stale = "To github.com:example/project.git\n \
11171 ! [rejected] HEAD -> issue-7 (stale info)\n\
11172 error: failed to push some refs to 'github.com:example/project.git'";
11173
11174 let err = push_error(
11175 &PushTarget::Origin,
11176 "issue-7",
11177 &crate::error::SparError::new(stale),
11178 )
11179 .to_string();
11180
11181 assert!(
11182 err.starts_with(
11183 "could not push to origin/issue-7. [rejected] HEAD -> issue-7 (stale info)"
11184 ),
11185 "{err}"
11186 );
11187 }
11188
11189 #[test]
11190 fn a_fork_is_reached_by_whatever_reaches_origin() {
11191 let cases = [
11192 (
11193 "git@github.com:example/project.git",
11194 Some("git@github.com:someone/project.git"),
11195 ),
11196 (
11198 "github.com:example/project.git",
11199 Some("github.com:someone/project.git"),
11200 ),
11201 (
11203 "git@github-work:example/project.git",
11204 Some("git@github-work:someone/project.git"),
11205 ),
11206 (
11208 "ssh://git@ssh.github.com:443/example/project.git",
11209 Some("ssh://git@ssh.github.com:443/someone/project.git"),
11210 ),
11211 (
11212 "https://github.com/example/project",
11213 Some("https://github.com/someone/project"),
11214 ),
11215 (
11216 "https://ghe.example.com/scm/example/project.git/",
11217 Some("https://ghe.example.com/scm/someone/project.git"),
11218 ),
11219 (
11220 "git@host:/srv/example/project.git",
11221 Some("git@host:/srv/someone/project.git"),
11222 ),
11223 ("/srv/git/project.git", None),
11224 ("https://github.com/project", None),
11225 ("", None),
11226 ];
11227 for (origin, want) in cases {
11228 assert_eq!(
11229 want.map(str::to_string),
11230 fork_url(origin, "someone/project"),
11231 "{origin}"
11232 );
11233 }
11234 }
11235
11236 #[test]
11237 fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11238 let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11239 "ssh_url":"git@github.com:someone/project.git",
11240 "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11241 let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11242 "sshUrl":"git@github.com:someone/project.git",
11243 "url":"https://github.com/someone/project"}]"#;
11244
11245 let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11246 assert_eq!("git@github-work:someone/project.git", fork.url);
11247 assert_eq!("someone", fork.owner);
11248 assert_eq!("someone/project", fork.full_name);
11249 let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11250 .unwrap()
11251 .unwrap();
11252 assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11253 assert_eq!(
11254 None,
11255 fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11256 );
11257
11258 let fork = fork_from_api(made, "https://github.com/project").unwrap();
11261 assert_eq!("https://github.com/someone/project.git", fork.url);
11262 let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11263 assert_eq!("git@github.com:someone/project.git", fork.url);
11264
11265 assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11266 assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11267 }
11268
11269 #[test]
11270 fn only_the_viewers_own_fork_can_be_pushed_to() {
11271 let pr = |owner: &str, name: &str| PrView {
11272 number: 9,
11273 url: String::new(),
11274 title: String::new(),
11275 body: String::new(),
11276 head_ref_name: "issue-7".into(),
11277 base_ref_name: "main".into(),
11278 state: "OPEN".into(),
11279 closing_issues_references: Vec::new(),
11280 is_cross_repository: true,
11281 head_repository_owner: Some(crate::model::Author {
11282 login: owner.into(),
11283 }),
11284 head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11285 };
11286 let origin = "git@github.com:example/project.git";
11287
11288 assert_eq!(
11289 Some(PushTarget::Fork(Fork {
11290 owner: "Someone".into(),
11291 full_name: "Someone/project".into(),
11292 url: "git@github.com:Someone/project.git".into(),
11293 })),
11294 own_fork_target(&pr("Someone", "project"), "someone", origin)
11295 );
11296 assert_eq!(
11297 None,
11298 own_fork_target(&pr("other", "project"), "someone", origin)
11299 );
11300 assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11301 assert_eq!(
11302 None,
11303 own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11304 );
11305 let mut unnamed = pr("someone", "project");
11306 unnamed.head_repository_owner = None;
11307 assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11308 }
11309
11310 #[cfg(unix)]
11313 #[test]
11314 fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11315 let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11316 test_git(
11317 &work,
11318 &[
11319 "push",
11320 "-q",
11321 fork.to_str().unwrap(),
11322 "issue-7:refs/heads/issue-7",
11323 ],
11324 );
11325 let published = test_git(&work, &["rev-parse", "issue-7"]);
11326 test_git(&work, &["checkout", "-q", "main"]);
11327 let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11328 let pr = PrView {
11329 number: 31,
11330 url: String::new(),
11331 title: String::new(),
11332 body: String::new(),
11333 head_ref_name: "issue-7".into(),
11334 base_ref_name: "main".into(),
11335 state: "OPEN".into(),
11336 closing_issues_references: Vec::new(),
11337 is_cross_repository: true,
11338 head_repository_owner: None,
11339 head_repository: None,
11340 };
11341
11342 let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11343
11344 assert_eq!("issue-7", branch);
11345 assert_eq!(
11346 published.trim(),
11347 test_git(&path, &["rev-parse", "HEAD"]).trim()
11348 );
11349 std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11350 test_git(&path, &["commit", "-qam", "Answer a comment"]);
11351 repo.push_to(&path, &target, &branch, Some(published.trim()))
11352 .unwrap();
11353 assert_eq!(
11354 test_git(&path, &["rev-parse", "HEAD"]).trim(),
11355 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11356 );
11357 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11358 .trim()
11359 .is_empty());
11360 }
11361
11362 #[cfg(unix)]
11363 #[test]
11364 fn with_forking_off_a_refused_push_says_why_and_how() {
11365 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11366
11367 let err = repo
11368 .publish_new_branch(&work, "issue-7", "main")
11369 .unwrap_err()
11370 .to_string();
11371
11372 assert!(
11373 err.contains("Permission to example/project.git denied to someone."),
11374 "{err}"
11375 );
11376 assert!(err.contains("fork_when_denied = true"), "{err}");
11377 assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11378 .trim()
11379 .is_empty());
11380 assert_eq!(
11381 WriteSummary {
11382 attempted: 1,
11383 failed: 1
11384 },
11385 repo.write_summary()
11386 );
11387 }
11388}
11389
11390#[cfg(test)]
11391mod comment_page_tests {
11392 use super::*;
11393
11394 #[test]
11395 fn a_single_merged_array_is_read() {
11396 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11397 assert_eq!(2, pages.len());
11398 assert_eq!(Some(2), pages[1]["id"].as_i64());
11399 }
11400
11401 #[test]
11402 fn concatenated_pages_from_an_older_gh_are_read_too() {
11403 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11404 assert_eq!(2, pages.len());
11405 }
11406
11407 #[test]
11411 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11412 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11413 let pages = parse_comment_pages(text);
11414 assert_eq!(2, pages.len(), "{pages:?}");
11415 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11416 }
11417
11418 #[test]
11419 fn empty_output_is_no_comments_not_a_panic() {
11420 assert!(parse_comment_pages("").is_empty());
11421 assert!(parse_comment_pages(" ").is_empty());
11422 assert!(parse_comment_pages("[]").is_empty());
11423 }
11424
11425 #[test]
11426 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11427 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11428 }
11429
11430 #[test]
11431 fn a_write_postcheck_rejects_truncated_comment_pages() {
11432 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11433 assert!(
11434 error.to_string().contains("unexpected comment pages"),
11435 "{error}"
11436 );
11437 }
11438
11439 #[test]
11440 fn a_write_postcheck_rejects_empty_or_non_array_output() {
11441 assert!(try_parse_comment_pages("").is_err());
11442 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11443 assert!(try_parse_comment_pages("[]").is_ok());
11444 }
11445
11446 #[test]
11447 fn state_is_found_in_the_last_matching_comment() {
11448 let payload = |round: u32| {
11449 format!(
11450 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11451 )
11452 };
11453 let text = serde_json::to_string(&serde_json::json!([
11454 {"id": 1, "body": payload(1)},
11455 {"id": 2, "body": "looks good to me"},
11456 {"id": 3, "body": payload(5)},
11457 ]))
11458 .unwrap();
11459 let pages = parse_comment_pages(&text);
11460 let last = pages
11461 .iter()
11462 .rev()
11463 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11464 .unwrap();
11465 assert_eq!(5, last.round);
11466 }
11467}
11468
11469#[cfg(test)]
11470mod linked_pr_tests {
11471 use super::*;
11472
11473 const REAL_PAYLOAD: &str = r#"[
11478 {"number":14252,"title":"fix: reject leading-dash branch names",
11479 "url":"https://github.com/cli/cli/pull/14252",
11480 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11481 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11482 "url":"https://github.com/cli/cli/issues/14238"}]},
11483 {"number":14217,"title":"another change",
11484 "url":"https://github.com/cli/cli/pull/14217",
11485 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11486 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11487 "url":"https://github.com/cli/cli/issues/9761"}]},
11488 {"number":14200,"title":"unlinked work",
11489 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11490 ]"#;
11491
11492 #[test]
11493 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11494 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11495 assert_eq!(14252, pr.number);
11496 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11497 }
11498
11499 #[test]
11500 fn the_right_pr_is_picked_out_of_several() {
11501 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11502 }
11503
11504 #[test]
11505 fn an_issue_nobody_is_working_on_finds_nothing() {
11506 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11507 }
11508
11509 #[test]
11510 fn an_unlinked_pr_is_never_matched() {
11511 for issue in [14200, 0, 1] {
11513 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11514 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11515 }
11516 }
11517 }
11518
11519 #[test]
11520 fn empty_or_broken_output_is_none_rather_than_a_panic() {
11521 assert!(find_linked_pr("", 1).is_none());
11522 assert!(find_linked_pr("[]", 1).is_none());
11523 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11524 assert!(find_linked_pr("[{\"number\":", 1).is_none());
11525 }
11526
11527 #[test]
11534 fn an_unreadable_list_is_not_an_empty_list() {
11535 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11536 assert!(linked_pr_rows("[{\"number\":").is_none());
11537 assert!(linked_pr_rows("").is_none());
11538
11539 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11540 assert!(empty.is_empty());
11541 assert!(pick_linked_pr(&empty, 14238).is_none());
11542
11543 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11544 assert_eq!(3, listed.len());
11545 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11546 }
11547
11548 #[test]
11550 fn pr_view_reads_the_cross_repository_flag() {
11551 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11552 "baseRefName":"main","state":"OPEN",
11553 "closingIssuesReferences":[],"isCrossRepository":true}"#;
11554 let pr: PrView = serde_json::from_str(json).unwrap();
11555 assert!(pr.is_cross_repository);
11556 assert!(pr.is_open());
11557
11558 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11559 assert!(
11560 !serde_json::from_str::<PrView>(&same_repo)
11561 .unwrap()
11562 .is_cross_repository
11563 );
11564 }
11565}
11566
11567#[cfg(test)]
11568mod min_number_tests {
11569 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11575 let mut numbers: Vec<i64> = open.to_vec();
11576 numbers.sort_unstable();
11577 if min_number > 0 {
11578 numbers.retain(|n| *n >= min_number);
11579 }
11580 numbers.truncate(limit);
11581 numbers
11582 }
11583
11584 #[test]
11585 fn the_floor_is_applied_before_the_cap_not_after() {
11586 let open = [12, 13, 14, 480, 481, 482];
11587 assert_eq!(vec![480, 481], pick(&open, 2, 480));
11588 assert!(!pick(&open, 2, 480).is_empty());
11591 }
11592
11593 #[test]
11594 fn no_floor_keeps_the_old_behaviour() {
11595 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11596 }
11597
11598 #[test]
11599 fn the_floor_is_inclusive() {
11600 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11601 }
11602
11603 #[test]
11604 fn a_floor_above_everything_open_yields_nothing() {
11605 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11606 }
11607}
11608
11609#[cfg(test)]
11610mod review_record_tests {
11611 use super::*;
11612 use serde_json::json;
11613
11614 #[test]
11615 fn prune_state_knows_which_files_belong_to_a_pull_request() {
11616 assert_eq!(Some(12), state_file_number("pr-12.json"));
11617 assert_eq!(Some(12), state_file_number("review-12.json"));
11618 assert_eq!(Some(12), state_file_number("checkin-12.json"));
11619 for shared in [
11620 "spend.json",
11621 "implementors.json",
11622 "contested.json",
11623 "listen.json",
11624 "pr-.json",
11625 "pr-12.json.tmp",
11626 "pr-x.json",
11627 ] {
11628 assert_eq!(None, state_file_number(shared), "{shared}");
11629 }
11630 }
11631
11632 #[test]
11633 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11634 let reviews = vec![
11635 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11636 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11637 ];
11638 assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11639 assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11640 assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11641 }
11642
11643 #[test]
11647 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11648 let older = vec![
11649 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11650 ];
11651 assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11652 let dismissed = vec![
11653 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11654 ];
11655 assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11656 }
11657
11658 #[test]
11659 fn a_response_splits_into_status_headers_and_body() {
11660 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";
11661 let resp = parse_api_response(text).expect("a response");
11662 assert_eq!(200, resp.status);
11663 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11664 assert_eq!(
11665 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11666 resp.headers.get("last-modified")
11667 );
11668 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11669
11670 let not_modified =
11671 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11672 assert_eq!(304, not_modified.status);
11673 assert!(not_modified.body.is_empty());
11674
11675 assert_eq!(None, parse_api_response(""));
11676 assert_eq!(None, parse_api_response("gh: command not found\n"));
11677 }
11678
11679 #[test]
11680 fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11681 let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11682 {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11683 {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11684 {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11685 {}
11686 ]}}}}})
11687 .to_string();
11688 let made = parse_review_requests(&text).expect("requests");
11689 assert_eq!(2, made.len());
11690 assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11691 assert_eq!("viewer", made[0].who.login);
11692 assert!(made[0].who.slug.is_empty());
11693 assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11694 assert!(made[1].who.login.is_empty());
11695 assert_eq!("example/ours", made[1].who.slug);
11696
11697 let none =
11698 json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11699 assert_eq!(
11700 Some(0),
11701 parse_review_requests(&none.to_string()).map(|m| m.len())
11702 );
11703 assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11704 assert!(parse_review_requests("gh: not json").is_none());
11705 }
11706
11707 #[test]
11708 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11709 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11710 let error = crate::error::SparError::call_failed("gh: boom");
11711 assert!(
11712 reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11713 );
11714
11715 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11716 let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11717 .unwrap_err();
11718 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11719
11720 let unreadable = Err(crate::error::SparError::new("no network"));
11721 let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11722 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11723 assert!(err.to_string().contains("could not be verified"), "{err}");
11724 }
11725 #[test]
11726 fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
11727 let comments = vec![
11728 InlineComment {
11729 path: "src/a.rs".into(),
11730 line: 12,
11731 start_line: None,
11732 body: "one".into(),
11733 },
11734 InlineComment {
11735 path: "src/b.rs".into(),
11736 line: 9,
11737 start_line: Some(4),
11738 body: "two".into(),
11739 },
11740 ];
11741 assert_eq!(
11742 json!({
11743 "commit_id": "abc",
11744 "event": "COMMENT",
11745 "body": "the body",
11746 "comments": [
11747 {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
11748 {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
11749 "start_side": "RIGHT", "body": "two"},
11750 ],
11751 }),
11752 review_payload("abc", "COMMENT", "the body", &comments)
11753 );
11754 }
11755
11756 #[test]
11757 fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
11758 let diff = r#"diff --git a/src/a.rs b/src/a.rs
11759index 1111111..2222222 100644
11760--- a/src/a.rs
11761+++ b/src/a.rs
11762@@ -1,3 +1,4 @@ fn x
11763 one
11764+++ an added line that looks like a header
11765 two
11766 three
11767@@ -20 +21,2 @@
11768-old
11769+new
11770+more
11771@@ -30,1 +32,0 @@
11772-gone
11773diff --git a/gone.rs b/gone.rs
11774deleted file mode 100644
11775--- a/gone.rs
11776+++ /dev/null
11777@@ -1,2 +0,0 @@
11778-a
11779-b
11780diff --git a/old.rs b/renamed.rs
11781similarity index 90%
11782rename from old.rs
11783rename to renamed.rs
11784--- a/old.rs
11785+++ b/renamed.rs
11786@@ -5,0 +6,1 @@
11787+inserted
11788diff --git a/with space.rs b/with space.rs
11789--- a/with space.rs
11790+++ b/with space.rs
11791@@ -2 +2 @@
11792-x
11793+y
11794diff --git "a/sp\tace.rs" "b/sp\tace.rs"
11795--- "a/sp\tace.rs"
11796+++ "b/sp\tace.rs"
11797@@ -1 +1 @@
11798-x
11799+y
11800"#;
11801 let hunks = new_side_hunks(diff);
11802 assert_eq!(
11803 Hunks::from([
11804 ("renamed.rs".to_string(), vec![(6, 6)]),
11805 ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
11806 ("with space.rs".to_string(), vec![(2, 2)]),
11807 ]),
11808 hunks
11809 );
11810 }
11811
11812 #[test]
11813 fn only_a_refusal_is_final_enough_to_send_a_review_again() {
11814 let error = |text: &str| crate::error::SparError::new(text);
11815 assert!(refused(&error(
11816 "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
11817 )));
11818 assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
11819 assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
11820 assert!(!refused(&error("command timed out after 300s")));
11821 }
11822
11823 #[test]
11824 fn a_hunk_header_with_its_counts_left_out_counts_one() {
11825 assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
11826 assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
11827 assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
11828 assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
11829 }
11830}