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 review_record_path(&self, number: i64) -> PathBuf {
5730 self.root
5731 .join(STATE_DIR)
5732 .join("state")
5733 .join(format!("review-{number}.json"))
5734 }
5735
5736 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5739 let path = self.review_record_path(number);
5740 let text = std::fs::read_to_string(&path).ok()?;
5741 match serde_json::from_str(&text) {
5742 Ok(record) => Some(record),
5743 Err(e) => {
5744 logdim!("could not read {}: {e}", path.display());
5745 None
5746 }
5747 }
5748 }
5749
5750 pub fn write_review_record(
5751 &self,
5752 number: i64,
5753 record: &crate::model::ReviewRecord,
5754 ) -> Result<()> {
5755 write_json_atomic(&self.review_record_path(number), record)
5756 }
5757
5758 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5773 let path = self.followups_path();
5774 let heading = format!("## {}", title.trim());
5775 for seen in [&path, &self.worked_followups_path()] {
5776 if let Ok(existing) = std::fs::read_to_string(seen) {
5777 if has_heading(&existing, title) {
5778 logdim!("follow-up already noted: {title}");
5779 return Followup::Covered(format!("note: {}", title.trim()));
5780 }
5781 }
5782 }
5783 if let Some(parent) = path.parent() {
5784 let _ = std::fs::create_dir_all(parent);
5785 }
5786 use std::io::Write;
5787 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5794 match std::fs::OpenOptions::new()
5795 .create(true)
5796 .append(true)
5797 .open(&path)
5798 {
5799 Ok(mut file) => match file.write_all(entry.as_bytes()) {
5800 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5801 Err(e) => {
5802 logdim!("could not write {}: {e}", path.display());
5803 Followup::Failed
5804 }
5805 },
5806 Err(e) => {
5807 logdim!("could not write {}: {e}", path.display());
5808 Followup::Failed
5809 }
5810 }
5811 }
5812
5813 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5818 let path = self.worked_followups_path();
5819 if let Some(parent) = path.parent() {
5820 let _ = std::fs::create_dir_all(parent);
5821 }
5822 use std::io::Write;
5823 let entry = format!(
5824 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5825 title.trim(),
5826 body.trim()
5827 );
5828 if let Ok(mut file) = std::fs::OpenOptions::new()
5829 .create(true)
5830 .append(true)
5831 .open(&path)
5832 {
5833 let _ = file.write_all(entry.as_bytes());
5834 }
5835 }
5836
5837 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5846 self.root
5847 .join(STATE_DIR)
5848 .join("reviews")
5849 .join(format!("pr-{number}.md"))
5850 }
5851
5852 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5858 let path = self.pending_comment_path(number);
5859 if let Some(parent) = path.parent() {
5860 std::fs::create_dir_all(parent)
5861 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5862 }
5863 std::fs::write(&path, text)
5864 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5865 Ok(path)
5866 }
5867
5868 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5869 std::fs::read_to_string(self.pending_comment_path(number)).ok()
5870 }
5871
5872 pub fn state_path(&self, number: i64) -> PathBuf {
5873 self.root
5874 .join(STATE_DIR)
5875 .join("state")
5876 .join(format!("pr-{number}.json"))
5877 }
5878
5879 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5880 let path = self.state_path(number);
5881 let text = std::fs::read_to_string(&path).ok()?;
5882 match serde_json::from_str(&text) {
5883 Ok(state) => Some(state),
5884 Err(_) => {
5885 logdim!("could not read {}, starting fresh", path.display());
5886 None
5887 }
5888 }
5889 }
5890
5891 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5892 if let Some(local) = self.read_local_state(pr.number) {
5893 return Some(local);
5894 }
5895 if self.state_store.writes_pr() {
5896 return self.read_pr_state(pr.number);
5897 }
5898 None
5899 }
5900
5901 pub(crate) fn read_state_for_head(
5902 &self,
5903 pr: &PrView,
5904 actual_head: &str,
5905 ) -> Option<PersistedState> {
5906 let local = self
5907 .state_store
5908 .writes_local()
5909 .then(|| self.read_local_state(pr.number))
5910 .flatten();
5911 let remote = self
5912 .state_store
5913 .writes_pr()
5914 .then(|| self.read_pr_state(pr.number))
5915 .flatten();
5916 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5917 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5918 self.remember_checkpoint(pr.number, checkpoint);
5919 }
5920 choose_state_for_head(candidates, actual_head)
5921 }
5922
5923 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5924 self.try_read_pr_state(number).ok().flatten()
5925 }
5926
5927 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5928 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5929 if let Some(state) = parse_state_comment(&body) {
5930 return Ok(Some(state));
5931 }
5932 }
5933 Ok(None)
5934 }
5935
5936 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5937 let remote_state = if self.state_store.writes_pr() {
5938 self.try_read_pr_state(number)
5939 } else {
5940 Ok(None)
5941 };
5942 self.write_state_after_remote_read(number, state, remote_state)
5943 }
5944
5945 fn write_state_after_remote_read(
5946 &self,
5947 number: i64,
5948 state: &PersistedState,
5949 remote_state: Result<Option<PersistedState>>,
5950 ) -> Result<()> {
5951 let remote_checkpoint = if self.state_store.writes_pr() {
5952 self.record_failed_write(remote_state)?
5953 .map(|saved| saved.checkpoint)
5954 .unwrap_or_default()
5955 } else {
5956 0
5957 };
5958 let local_checkpoint = self
5959 .state_store
5960 .writes_local()
5961 .then(|| self.read_local_state(number))
5962 .flatten()
5963 .map(|saved| saved.checkpoint)
5964 .unwrap_or_default();
5965 let mut stamped = state.clone();
5966 stamped.checkpoint = state
5967 .checkpoint
5968 .max(local_checkpoint)
5969 .max(remote_checkpoint)
5970 .max(self.remembered_checkpoint(number))
5971 .saturating_add(1);
5972 self.remember_checkpoint(number, stamped.checkpoint);
5973 if self.state_store.writes_local() {
5974 write_json_atomic(&self.state_path(number), &stamped)?;
5975 }
5976 if self.state_store.writes_pr() {
5977 self.write_pr_state(number, &stamped)?;
5978 }
5979 Ok(())
5980 }
5981
5982 fn remembered_checkpoint(&self, number: i64) -> u64 {
5983 self.checkpoints
5984 .lock()
5985 .unwrap_or_else(std::sync::PoisonError::into_inner)
5986 .get(&number)
5987 .copied()
5988 .unwrap_or_default()
5989 }
5990
5991 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
5992 let mut checkpoints = self
5993 .checkpoints
5994 .lock()
5995 .unwrap_or_else(std::sync::PoisonError::into_inner);
5996 let saved = checkpoints.entry(number).or_default();
5997 *saved = (*saved).max(checkpoint);
5998 }
5999
6000 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6001 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6005 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6006 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6007 if let Some(id) = comment_id {
6008 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6009 let field = format!("body={body}");
6010 let written = self
6011 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6012 .map(|_| ());
6013 return self.record_write(written);
6014 }
6015 let written = self
6016 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6017 .map(|_| ());
6018 self.record_write(written)
6019 }
6020
6021 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6029 self.try_issue_comments(number).unwrap_or_default()
6030 }
6031
6032 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6033 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6034 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6035 }
6036
6037 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6044 let viewer = self.viewer_login()?.to_string();
6045 let mut mine = Vec::new();
6046 let mut theirs = Vec::new();
6047 for comment in self.try_issue_comments(number)? {
6048 let Some(body) = comment.get("body").and_then(Value::as_str) else {
6049 continue;
6050 };
6051 if !body.contains("spar:state") {
6052 continue;
6053 }
6054 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6055 continue;
6056 };
6057 let author = comment
6058 .get("user")
6059 .and_then(|u| u.get("login"))
6060 .and_then(Value::as_str)
6061 .unwrap_or_default();
6062 if is_state_comment(body, author, &viewer) {
6063 mine.push((id, body.to_string()));
6064 } else {
6065 theirs.push(format!("@{author}"));
6066 }
6067 }
6068 if !theirs.is_empty() {
6069 logdim!(
6070 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6071 own state block is patched or deleted.",
6072 theirs.len(),
6073 theirs.join(", ")
6074 );
6075 }
6076 Ok(mine)
6077 }
6078
6079 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6080 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6081 }
6082
6083 pub fn clear_state(&self, number: i64) {
6085 let path = self.state_path(number);
6086 let _ = std::fs::remove_file(&path);
6087 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6088 }
6089
6090 pub fn prune_state(&self) -> Vec<String> {
6097 let base = self.root.join(STATE_DIR).join("state");
6098 let Ok(entries) = std::fs::read_dir(&base) else {
6099 return Vec::new();
6100 };
6101 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6102 for name in entries
6103 .flatten()
6104 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6105 {
6106 if let Some(number) = state_file_number(&name) {
6107 by_number.entry(number).or_default().push(name);
6108 }
6109 }
6110
6111 let mut removed = Vec::new();
6112 for (number, mut names) in by_number {
6113 if !is_finished(&self.pr_state(number)) {
6114 continue;
6115 }
6116 names.sort();
6117 for name in names {
6118 let _ = std::fs::remove_file(base.join(&name));
6119 removed.push(format!("state {name}"));
6120 }
6121 }
6122 removed
6123 }
6124
6125 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6129 #[derive(Deserialize)]
6130 struct Row {
6131 number: i64,
6132 }
6133 let numbers = match numbers {
6134 Some(numbers) => numbers,
6135 None => {
6136 let listed: Result<Vec<i64>> = (|| {
6137 let text = self.gh(&[
6138 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6139 ])?;
6140 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6141 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6142 Ok(rows.into_iter().map(|row| row.number).collect())
6143 })();
6144 match self.record_failed_write(listed) {
6145 Ok(numbers) => numbers,
6146 Err(e) => {
6147 logdim!("could not inspect pull requests for state cleanup: {e}");
6148 return Vec::new();
6149 }
6150 }
6151 }
6152 };
6153
6154 let mut removed = Vec::new();
6155 for number in numbers {
6156 let state = match self.record_failed_write(self.try_pr_state(number)) {
6157 Ok(state) => state,
6158 Err(e) => {
6159 logdim!("could not inspect PR #{number} for state cleanup: {e}");
6160 continue;
6161 }
6162 };
6163 if !is_finished(&state) {
6164 continue;
6165 }
6166 let comments = match self.record_failed_write(self.try_state_comments(number)) {
6167 Ok(comments) => comments,
6168 Err(e) => {
6169 logdim!("could not inspect state comments on PR #{number}: {e}");
6170 continue;
6171 }
6172 };
6173 for (id, _) in comments {
6174 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6175 let deleted = self
6176 .gh(&["api", "-X", "DELETE", &path, "--silent"])
6177 .map(|_| ());
6178 match self.record_write(deleted) {
6179 Ok(()) => removed.push(format!("state comment on PR #{number}")),
6180 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6181 }
6182 }
6183 }
6184 removed
6185 }
6186
6187 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6194 let base = self.root.join(WORKTREE_DIR);
6195 let mut removed = Vec::new();
6196 let known = self.known_branches();
6197
6198 if let Ok(entries) = std::fs::read_dir(&base) {
6199 let mut names: Vec<String> = entries
6200 .flatten()
6201 .filter(|e| e.path().is_dir())
6202 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6203 .collect();
6204 names.sort();
6205
6206 for name in names {
6207 if let Some(rest) = name.strip_prefix("review-") {
6210 let number: i64 = rest.parse().unwrap_or(-1);
6211 if !(force_all || is_finished(&self.pr_state(number))) {
6212 continue;
6213 }
6214 let path = base.join(&name);
6215 if force_all {
6216 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6217 if !belongs {
6218 return Ok(false);
6219 }
6220 let local_ref = review_ref(number);
6221 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6222 return Ok(false);
6223 }
6224 let head = self.head_oid_checked(&path)?;
6225 let recorded = self
6226 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6227 .trim()
6228 .to_string();
6229 Ok(head == recorded)
6230 });
6231 match owned {
6232 Ok(true) => {}
6233 Ok(false) => {
6234 logdim!(
6235 "kept {} because no matching SPAR review reference proves \
6236 ownership",
6237 path.display()
6238 );
6239 continue;
6240 }
6241 Err(e) => {
6242 logdim!(
6243 "kept {} because review ownership could not be verified: {}",
6244 path.display(),
6245 e.last_line()
6246 );
6247 continue;
6248 }
6249 }
6250 } else {
6251 if let Err(e) = self.refuse_review_worktree_changes(number) {
6252 logdim!(
6253 "kept {} because its review state could not be verified as \
6254 disposable: {}",
6255 path.display(),
6256 e.last_line()
6257 );
6258 continue;
6259 }
6260 }
6261 if force_all {
6262 if self.remove_worktree_at_force(&path) {
6263 self.git_try(&["update-ref", "-d", &review_ref(number)]);
6264 }
6265 } else {
6266 self.release_review_worktree(number);
6267 }
6268 if !path.exists() {
6269 removed.push(name);
6270 }
6271 continue;
6272 }
6273 let branch = format!("{}{name}", self.branch_prefix);
6274 if !(force_all || self.worktree_is_done(&branch)) {
6275 continue;
6276 }
6277 if !known.contains_key(&branch) {
6278 logdim!("kept {branch} because it has no branch record");
6279 continue;
6280 }
6281 let path = base.join(&name);
6282 if !force_all {
6283 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6284 Ok(true) => {
6285 logdim!(
6286 "kept {} because it contains uncommitted changes or ignored files",
6287 path.display()
6288 );
6289 continue;
6290 }
6291 Err(e) => {
6292 logdim!(
6293 "kept {} because its Git state could not be checked: {}",
6294 path.display(),
6295 e.last_line()
6296 );
6297 continue;
6298 }
6299 Ok(false) => {}
6300 }
6301 match self.branch_deletion_is_safe(&branch) {
6302 Ok(true) => {}
6303 Ok(false) => {
6304 logdim!(
6305 "kept {branch} because no surviving ref preserves its tip or \
6306 reflog-only commits"
6307 );
6308 continue;
6309 }
6310 Err(e) => {
6311 logdim!(
6312 "kept {branch} because preservation could not be verified: {}",
6313 e.last_line()
6314 );
6315 continue;
6316 }
6317 }
6318 }
6319 let removed_worktree = if force_all {
6320 self.remove_worktree_at_force(&path)
6321 } else {
6322 match self.remove_worktree_at(&path) {
6323 Ok(removed) => removed,
6324 Err(error) => {
6325 logdim!(
6326 "kept {branch} and {} because removal did not reach a confirmed \
6327 quiet point: {}",
6328 path.display(),
6329 error.last_line()
6330 );
6331 false
6332 }
6333 }
6334 };
6335 if !removed_worktree {
6336 continue;
6337 }
6338 if force_all {
6339 self.git_try(&["branch", "-D", &branch]);
6340 self.forget_branch(&branch);
6341 } else {
6342 match self.delete_branch_if_safe(&branch) {
6343 Ok(true) => self.forget_branch(&branch),
6344 Ok(false) => logdim!(
6345 "kept {branch} because its tip or reflog changed before deletion"
6346 ),
6347 Err(error) => logdim!(
6348 "kept {branch} because deletion safety could not be rechecked: {}",
6349 error.last_line()
6350 ),
6351 }
6352 }
6353 removed.push(name);
6354 }
6355 }
6356 removed.extend(self.prune_branches(force_all));
6357 removed
6358 }
6359
6360 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6367 let known = self.known_branches();
6368 let branches: Vec<String> = known.keys().cloned().collect();
6369 if branches.is_empty() {
6370 return Vec::new();
6371 }
6372
6373 let checked_out: Vec<String> = self
6374 .git_try(&["worktree", "list", "--porcelain"])
6375 .lines()
6376 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6377 .collect();
6378
6379 let existing: Vec<String> = self
6382 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6383 .lines()
6384 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6385 .collect();
6386
6387 let mut removed = Vec::new();
6388 for branch in branches {
6389 if !existing.contains(&branch) {
6390 self.forget_branch(&branch); continue;
6392 }
6393 if checked_out.contains(&branch) {
6394 continue;
6395 }
6396 if !(force_all || self.worktree_is_done(&branch)) {
6397 continue;
6398 }
6399 if !force_all {
6400 let Some(_record) = known.get(&branch) else {
6401 continue;
6402 };
6403 match self.branch_deletion_is_safe(&branch) {
6404 Ok(true) => {}
6405 Ok(false) => {
6406 logdim!(
6407 "kept {branch} because no surviving ref preserves its tip or \
6408 reflog-only commits"
6409 );
6410 continue;
6411 }
6412 Err(e) => {
6413 logdim!(
6414 "kept {branch} because preservation could not be verified: {}",
6415 e.last_line()
6416 );
6417 continue;
6418 }
6419 }
6420 }
6421 let deleted = if force_all {
6422 self.git(&["branch", "-D", &branch]).map(|_| true)
6423 } else {
6424 self.delete_branch_if_safe(&branch)
6425 };
6426 match deleted {
6427 Ok(true) => {
6428 self.forget_branch(&branch);
6429 removed.push(format!("branch {branch}"));
6430 }
6431 Ok(false) => {
6432 logdim!("kept {branch} because its tip or reflog changed before deletion");
6433 }
6434 Err(e) => {
6435 logdim!("could not delete {branch}: {}", e.last_line());
6438 }
6439 }
6440 }
6441 removed
6442 }
6443
6444 fn worktree_is_done(&self, branch: &str) -> bool {
6446 #[derive(Deserialize)]
6447 struct Row {
6448 state: String,
6449 }
6450 let entry = branch
6451 .strip_prefix(self.branch_prefix.as_str())
6452 .unwrap_or(branch);
6453 if let Some(rest) = entry.strip_prefix("pr-") {
6454 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6455 }
6456 if entry.starts_with("issue-") || entry.starts_with("split-") {
6460 let text = self.gh_try(&[
6461 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6462 ]);
6463 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6464 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6465 }
6466 false
6467 }
6468}
6469
6470pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6482 match probe() {
6483 Ok(value) => Ok(value),
6484 Err(first) => {
6485 logdim!("retrying a worktree probe after: {}", first.last_line());
6486 probe()
6487 }
6488 }
6489}
6490
6491pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6497 let root = std::fs::canonicalize(cwd)
6498 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6499 let mut files = BTreeMap::new();
6500 let mut visited = BTreeSet::new();
6501 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6502 Ok(AttributeState { files })
6503}
6504
6505fn collect_attribute_files(
6506 root: &Path,
6507 repository: &Path,
6508 prefix: &Path,
6509 visited: &mut BTreeSet<PathBuf>,
6510 files: &mut BTreeMap<PathBuf, [u8; 32]>,
6511) -> Result<()> {
6512 let canonical = std::fs::canonicalize(repository)
6513 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6514 if !visited.insert(canonical) {
6515 bail!("submodule recursion revisited {}", repository.display());
6516 }
6517 let entries = index_entries(repository)?;
6518 let mut paths: BTreeSet<PathBuf> = entries
6519 .iter()
6520 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6521 .map(|entry| entry.path.clone())
6522 .collect();
6523 paths.extend(untracked_attribute_paths(repository)?);
6529 for path in paths {
6530 let from_root = prefix.join(&path);
6531 let state = attribute_file_fingerprint(&root.join(&from_root))?;
6532 files.insert(from_root, state);
6533 }
6534 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6535 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6536 continue;
6537 };
6538 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6539 }
6540 Ok(())
6541}
6542
6543pub(crate) fn uncertain_worktree_change(
6546 cwd: &Path,
6547 message: impl Into<String>,
6548) -> crate::error::SparError {
6549 let message = message.into();
6550 let marker = write_recovery_marker(cwd, &message);
6551 let note = match marker {
6552 Ok(path) => format!(" Recovery marker: {}.", path.display()),
6553 Err(e) => format!(
6554 " A recovery marker could not be written: {}.",
6555 e.last_line()
6556 ),
6557 };
6558 crate::error::SparError::uncertain_write(format!("{message}{note}"))
6559}
6560
6561fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6562 use std::sync::atomic::{AtomicU32, Ordering};
6563 static NEXT: AtomicU32 = AtomicU32::new(0);
6564 for _ in 0..1000 {
6565 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6566 let path = cwd.join(format!(
6567 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6568 std::process::id()
6569 ));
6570 let mut options = OpenOptions::new();
6571 options.write(true).create_new(true);
6572 #[cfg(unix)]
6573 {
6574 use std::os::unix::fs::OpenOptionsExt;
6575 options.mode(0o600);
6576 }
6577 match options.open(&path) {
6578 Ok(mut file) => {
6579 file.write_all(detail.as_bytes())
6580 .and_then(|_| file.write_all(b"\n"))
6581 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6582 return Ok(path);
6583 }
6584 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6585 Err(e) => {
6586 return Err(spar_err!(
6587 "could not create a recovery marker in {}: {e}",
6588 cwd.display()
6589 ))
6590 }
6591 }
6592 }
6593 bail!(
6594 "could not choose a free recovery marker name in {}",
6595 cwd.display()
6596 )
6597}
6598
6599fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6604 let mut argv = vec![
6605 "git".to_string(),
6606 "-c".to_string(),
6607 "maintenance.auto=false".to_string(),
6608 "-c".to_string(),
6609 "gc.auto=0".to_string(),
6610 ];
6611 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6612 argv
6613}
6614
6615fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6616 let mut argv = git_without_maintenance_argv(&[]);
6617 argv.extend([
6618 "-c".to_string(),
6619 "core.fsmonitor=".to_string(),
6620 "-c".to_string(),
6621 "commit.gpgsign=false".to_string(),
6622 "-c".to_string(),
6623 "core.hooksPath=/dev/null".to_string(),
6624 ]);
6625 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6626 argv
6627}
6628
6629pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6636 let root = std::fs::canonicalize(cwd)
6637 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6638 let mut files = BTreeMap::new();
6639 let mut ignored = BTreeSet::new();
6640 let mut visited = BTreeSet::new();
6641 collect_untracked_files(
6642 &root,
6643 &root,
6644 Path::new(""),
6645 &mut visited,
6646 &mut files,
6647 &mut ignored,
6648 )?;
6649 Ok(IgnoredState { files, ignored })
6650}
6651
6652fn collect_untracked_files(
6653 root: &Path,
6654 repository: &Path,
6655 prefix: &Path,
6656 visited: &mut BTreeSet<PathBuf>,
6657 files: &mut BTreeMap<PathBuf, UntrackedFile>,
6658 ignored: &mut BTreeSet<PathBuf>,
6659) -> Result<()> {
6660 let canonical = std::fs::canonicalize(repository)
6661 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6662 if !visited.insert(canonical.clone()) {
6663 bail!("submodule recursion revisited {}", canonical.display());
6664 }
6665 let listed = run_git_bytes(
6669 repository,
6670 &["ls-files", "--others", "--exclude-standard", "-z"],
6671 )?;
6672 if !listed.is_empty() && !listed.ends_with(&[0]) {
6673 bail!(
6674 "git returned an unterminated untracked-file list for {}",
6675 repository.display()
6676 );
6677 }
6678
6679 for raw in listed
6680 .split(|byte| *byte == 0)
6681 .filter(|raw| !raw.is_empty())
6682 {
6683 let (relative, nested) = untracked_record(raw, "untracked")?;
6684 let from_root = prefix.join(&relative);
6685 if is_spar_own_path(&from_root) {
6686 continue;
6687 }
6688 let absolute = root.join(&from_root);
6689 let fingerprint = if nested {
6690 nested_repository_fingerprint(&absolute)?
6691 } else {
6692 ignored_file_fingerprint(&absolute)?
6693 };
6694 if files.insert(from_root.clone(), fingerprint).is_some() {
6695 bail!(
6696 "git returned the untracked path more than once: {:?}",
6697 from_root
6698 );
6699 }
6700 }
6701
6702 let ignored_listed = run_git_bytes(
6703 repository,
6704 &[
6705 "ls-files",
6706 "--others",
6707 "--ignored",
6708 "--exclude-standard",
6709 "-z",
6710 ],
6711 )?;
6712 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6713 bail!(
6714 "git returned an unterminated ignored-file list for {}",
6715 repository.display()
6716 );
6717 }
6718 for raw in ignored_listed
6724 .split(|byte| *byte == 0)
6725 .filter(|raw| !raw.is_empty())
6726 {
6727 let (relative, nested) = untracked_record(raw, "ignored")?;
6728 let from_root = prefix.join(relative);
6729 if is_spar_own_path(&from_root) {
6730 continue;
6731 }
6732 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
6733 let absolute = root.join(&from_root);
6734 let fingerprint = if nested {
6735 nested_repository_fingerprint(&absolute)?
6736 } else {
6737 ignored_file_fingerprint(&absolute)?
6738 };
6739 slot.insert(fingerprint);
6740 }
6741 if !ignored.insert(from_root.clone()) {
6742 bail!(
6743 "git returned the ignored path more than once: {:?}",
6744 from_root
6745 );
6746 }
6747 }
6748
6749 for link in gitlinks(repository)? {
6750 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6751 continue;
6752 };
6753 collect_untracked_files(
6754 root,
6755 &submodule,
6756 &prefix.join(&link.path),
6757 visited,
6758 files,
6759 ignored,
6760 )?;
6761 }
6762 Ok(())
6763}
6764
6765fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
6766 let argv = git_without_automation_argv(args);
6767 proc::run_bytes(
6768 &argv,
6769 &ExecOpts::new()
6770 .cwd(cwd)
6771 .timeout_secs(30)
6772 .stop_descendants(true),
6773 )
6774}
6775
6776fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
6777 let argv = git_without_automation_argv(args);
6778 proc::run(
6779 &argv,
6780 &ExecOpts::new()
6781 .cwd(cwd)
6782 .timeout_secs(30)
6783 .stop_descendants(true),
6784 )
6785}
6786
6787fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
6788 let path = path.to_str().ok_or_else(|| {
6789 spar_err!(
6790 "cannot verify filtered content for a non-UTF-8 path in {}",
6791 cwd.display()
6792 )
6793 })?;
6794 let path_arg = format!("--path={path}");
6795 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
6796 Ok(Sha256::digest(bytes).into())
6797}
6798
6799fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
6800 let relative = path_from_git_bytes(raw)?;
6801 if relative.is_absolute()
6802 || relative.components().any(|component| {
6803 matches!(
6804 component,
6805 std::path::Component::ParentDir
6806 | std::path::Component::RootDir
6807 | std::path::Component::Prefix(_)
6808 )
6809 })
6810 {
6811 bail!("git returned an unsafe {kind} path: {:?}", relative);
6812 }
6813 Ok(relative)
6814}
6815
6816fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6825 let nested = raw.last() == Some(&b'/');
6826 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6827 if trimmed.is_empty() {
6828 bail!("git returned an empty {kind} path");
6829 }
6830 Ok((safe_git_path(trimmed, kind)?, nested))
6831}
6832
6833fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6834 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6835 if !listed.is_empty() && !listed.ends_with(&[0]) {
6836 bail!(
6837 "git returned an unterminated index listing for {}",
6838 cwd.display()
6839 );
6840 }
6841 let mut entries = Vec::new();
6842 for record in listed
6843 .split(|byte| *byte == 0)
6844 .filter(|record| !record.is_empty())
6845 {
6846 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6847 bail!(
6848 "git returned a malformed index record for {}",
6849 cwd.display()
6850 );
6851 };
6852 let header = &record[..tab];
6853 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6854 if fields.len() != 3 {
6855 bail!(
6856 "git returned a malformed index header for {}",
6857 cwd.display()
6858 );
6859 }
6860 if fields[2] != b"0" {
6861 continue;
6862 }
6863 let mode = std::str::from_utf8(fields[0])
6864 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6865 .to_string();
6866 let oid = std::str::from_utf8(fields[1])
6867 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6868 .to_string();
6869 entries.push(IndexEntry {
6870 path: safe_git_path(&record[tab + 1..], "index")?,
6871 mode,
6872 oid,
6873 });
6874 }
6875 Ok(entries)
6876}
6877
6878fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6880 let listed = run_git_bytes(
6881 cwd,
6882 &[
6883 "ls-files",
6884 "--others",
6885 "--exclude-standard",
6886 "-z",
6887 "--",
6888 ".gitattributes",
6889 ":(glob)**/.gitattributes",
6890 ],
6891 )?;
6892 if !listed.is_empty() && !listed.ends_with(&[0]) {
6893 bail!(
6894 "git returned an unterminated attribute-file listing for {}",
6895 cwd.display()
6896 );
6897 }
6898 listed
6899 .split(|byte| *byte == 0)
6900 .filter(|record| !record.is_empty())
6901 .map(|raw| safe_git_path(raw, "attribute"))
6902 .collect()
6903}
6904
6905fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6906 if !untracked_attribute_paths(cwd)?.is_empty() {
6907 return Ok(true);
6908 }
6909
6910 let index = index_entries(cwd)?
6911 .into_iter()
6912 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6913 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6914 .collect::<BTreeMap<_, _>>();
6915 let head = tree_entries(cwd, "HEAD")?
6916 .into_iter()
6917 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6918 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6919 .collect::<BTreeMap<_, _>>();
6920 if index != head {
6921 return Ok(true);
6922 }
6923
6924 let effective = check_attributes(cwd, index.keys().cloned())?;
6925 let config = CheckoutConfig::read(cwd)?;
6926 for (path, (_mode, oid)) in index {
6927 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6928 return Ok(true);
6929 };
6930 let attributes = effective
6931 .get(&path)
6932 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6933 if allows_expected_crlf(&config, attributes)? {
6934 if worktree.mode == "120000" {
6935 return Ok(true);
6936 }
6937 let (normalized, every_lf_was_crlf) =
6938 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
6939 if !every_lf_was_crlf || normalized != oid {
6940 return Ok(true);
6941 }
6942 } else if worktree.raw_oid != oid {
6943 return Ok(true);
6944 }
6945 }
6946 Ok(false)
6947}
6948
6949fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
6950 Ok(index_entries(cwd)?
6951 .into_iter()
6952 .filter(|entry| entry.mode == "160000")
6953 .map(|entry| Gitlink {
6954 path: entry.path,
6955 oid: entry.oid,
6956 })
6957 .collect())
6958}
6959
6960fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
6961 let mut tracked = BTreeMap::new();
6962 for entry in index_entries(cwd)? {
6963 if entry.mode == "160000" {
6964 continue;
6965 }
6966 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
6967 tracked.insert(
6968 entry.path,
6969 TrackedEntry {
6970 index_mode: entry.mode,
6971 index_oid: entry.oid,
6972 worktree,
6973 },
6974 );
6975 }
6976 Ok(tracked)
6977}
6978
6979fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
6980 let metadata = match std::fs::symlink_metadata(path) {
6981 Ok(metadata) => metadata,
6982 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6983 Err(e) => {
6984 return Err(spar_err!(
6985 "could not inspect tracked file {}: {e}",
6986 path.display()
6987 ))
6988 }
6989 };
6990 let mut fingerprint = Sha256::new();
6991 if metadata.file_type().is_symlink() {
6992 let target = std::fs::read_link(path)
6993 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
6994 let bytes = os_str_bytes(target.as_os_str())?;
6995 fingerprint.update(b"symlink\0");
6996 fingerprint.update(&bytes);
6997 let content = Sha256::digest(&bytes).into();
6998 return Ok(Some(WorktreeFile {
6999 mode: "120000".to_string(),
7000 #[cfg(unix)]
7001 permissions: 0,
7002 raw_oid: git_blob_oid(oid_len, &bytes)?,
7003 fingerprint: fingerprint.finalize().into(),
7004 content,
7005 }));
7006 }
7007 if !metadata.is_file() {
7008 bail!("tracked path {} is not a file or symlink", path.display());
7009 }
7010
7011 let mut options = OpenOptions::new();
7012 options.read(true);
7013 #[cfg(unix)]
7014 {
7015 use std::os::unix::fs::OpenOptionsExt;
7016 options.custom_flags(libc::O_NOFOLLOW);
7017 }
7018 let mut file = options
7019 .open(path)
7020 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7021 let before = file
7022 .metadata()
7023 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7024 let mode = tracked_file_mode(&before);
7025 #[cfg(unix)]
7026 let permissions = {
7027 use std::os::unix::fs::MetadataExt;
7028 before.mode() & 0o7777
7029 };
7030 fingerprint.update(b"file\0");
7031 fingerprint.update(mode.as_bytes());
7032 #[cfg(unix)]
7033 fingerprint.update(permissions.to_le_bytes());
7034 fingerprint.update(before.len().to_le_bytes());
7035 let mut content = Sha256::new();
7036 let header = format!("blob {}\0", before.len());
7037 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7038 let mut buf = [0u8; 64 * 1024];
7039 loop {
7040 let read = file
7041 .read(&mut buf)
7042 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7043 if read == 0 {
7044 break;
7045 }
7046 fingerprint.update(&buf[..read]);
7047 content.update(&buf[..read]);
7048 object.update(&buf[..read]);
7049 }
7050 let after = file
7051 .metadata()
7052 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7053 if before.len() != after.len()
7054 || before.modified().ok() != after.modified().ok()
7055 || before.permissions() != after.permissions()
7056 {
7057 bail!(
7058 "tracked file {} changed while it was being inspected",
7059 path.display()
7060 );
7061 }
7062 let current = std::fs::symlink_metadata(path)
7063 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7064 if !same_file(&after, ¤t) {
7065 bail!(
7066 "tracked file {} was replaced while it was being inspected",
7067 path.display()
7068 );
7069 }
7070 Ok(Some(WorktreeFile {
7071 mode,
7072 #[cfg(unix)]
7073 permissions,
7074 raw_oid: object.finish(),
7075 fingerprint: fingerprint.finalize().into(),
7076 content: content.finalize().into(),
7077 }))
7078}
7079
7080fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7081 let metadata = std::fs::symlink_metadata(path)
7082 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7083 let mut digest = Sha256::new();
7084 if metadata.file_type().is_symlink() {
7085 digest.update(b"symlink\0");
7086 let target = std::fs::read_link(path)
7087 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7088 digest.update(os_str_bytes(target.as_os_str())?);
7089 return Ok(digest.finalize().into());
7090 }
7091 if !metadata.is_file() {
7092 bail!("attribute path {} is not a file or symlink", path.display());
7093 }
7094 let mut options = OpenOptions::new();
7095 options.read(true);
7096 #[cfg(unix)]
7097 {
7098 use std::os::unix::fs::OpenOptionsExt;
7099 options.custom_flags(libc::O_NOFOLLOW);
7100 }
7101 let mut file = options
7102 .open(path)
7103 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7104 let before = file
7105 .metadata()
7106 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7107 digest.update(b"file\0");
7108 let mut buf = [0u8; 64 * 1024];
7109 loop {
7110 let read = file
7111 .read(&mut buf)
7112 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7113 if read == 0 {
7114 break;
7115 }
7116 digest.update(&buf[..read]);
7117 }
7118 let after = file
7119 .metadata()
7120 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7121 let current = std::fs::symlink_metadata(path)
7122 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7123 if before.len() != after.len()
7124 || before.modified().ok() != after.modified().ok()
7125 || !same_file(&after, ¤t)
7126 {
7127 bail!(
7128 "attribute file {} changed while it was being inspected",
7129 path.display()
7130 );
7131 }
7132 Ok(digest.finalize().into())
7133}
7134
7135enum ObjectHasher {
7136 Sha1(Sha1),
7137 Sha256(Sha256),
7138}
7139
7140impl ObjectHasher {
7141 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7142 let mut hasher = match oid_len {
7143 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7144 64 => Self::Sha256(Sha256::new()),
7145 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7146 };
7147 hasher.update(header);
7148 Ok(hasher)
7149 }
7150
7151 fn update(&mut self, bytes: &[u8]) {
7152 match self {
7153 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7154 Self::Sha256(hasher) => hasher.update(bytes),
7155 }
7156 }
7157
7158 fn finish(self) -> String {
7159 let bytes = match self {
7160 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7161 Self::Sha256(hasher) => hasher.finalize().to_vec(),
7162 };
7163 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7164 }
7165}
7166
7167fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7168 let header = format!("blob {}\0", bytes.len());
7169 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7170 hasher.update(bytes);
7171 Ok(hasher.finish())
7172}
7173
7174fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7175 let mut first = open_regular_file(path)?;
7176 let first_before = first
7177 .metadata()
7178 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7179 let mut raw_len = 0u64;
7180 let mut crlf_pairs = 0u64;
7181 let mut previous_was_cr = false;
7182 let mut every_lf_was_crlf = true;
7183 let mut buf = [0u8; 64 * 1024];
7184 loop {
7185 let read = first
7186 .read(&mut buf)
7187 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7188 if read == 0 {
7189 break;
7190 }
7191 raw_len = raw_len
7192 .checked_add(read as u64)
7193 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7194 for byte in &buf[..read] {
7195 if *byte == b'\n' {
7196 if previous_was_cr {
7197 crlf_pairs += 1;
7198 } else {
7199 every_lf_was_crlf = false;
7200 }
7201 }
7202 previous_was_cr = *byte == b'\r';
7203 }
7204 }
7205 let first_after = first
7206 .metadata()
7207 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7208 let current = std::fs::symlink_metadata(path)
7209 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7210 if raw_len != first_before.len()
7211 || !stable_file_metadata(&first_before, &first_after)
7212 || !stable_file_metadata(&first_after, ¤t)
7213 {
7214 bail!(
7215 "tracked file {} changed while line endings were inspected",
7216 path.display()
7217 );
7218 }
7219
7220 let normalized_len = raw_len
7221 .checked_sub(crlf_pairs)
7222 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7223 let header = format!("blob {normalized_len}\0");
7224 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7225 let mut second = open_regular_file(path)?;
7226 let second_before = second
7227 .metadata()
7228 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7229 if !stable_file_metadata(&first_after, &second_before) {
7230 bail!(
7231 "tracked file {} changed between line-ending checks",
7232 path.display()
7233 );
7234 }
7235 let mut pending_cr = false;
7236 loop {
7237 let read = second
7238 .read(&mut buf)
7239 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7240 if read == 0 {
7241 break;
7242 }
7243 for byte in &buf[..read] {
7244 if pending_cr {
7245 if *byte == b'\n' {
7246 object.update(b"\n");
7247 pending_cr = false;
7248 continue;
7249 }
7250 object.update(b"\r");
7251 pending_cr = false;
7252 }
7253 if *byte == b'\r' {
7254 pending_cr = true;
7255 } else {
7256 object.update(std::slice::from_ref(byte));
7257 }
7258 }
7259 }
7260 if pending_cr {
7261 object.update(b"\r");
7262 }
7263 let second_after = second
7264 .metadata()
7265 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7266 let current = std::fs::symlink_metadata(path)
7267 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7268 if !stable_file_metadata(&second_before, &second_after)
7269 || !stable_file_metadata(&second_after, ¤t)
7270 {
7271 bail!(
7272 "tracked file {} changed while line endings were hashed",
7273 path.display()
7274 );
7275 }
7276 Ok((object.finish(), every_lf_was_crlf))
7277}
7278
7279fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7280 let mut options = OpenOptions::new();
7281 options.read(true);
7282 #[cfg(unix)]
7283 {
7284 use std::os::unix::fs::OpenOptionsExt;
7285 options.custom_flags(libc::O_NOFOLLOW);
7286 }
7287 let file = options
7288 .open(path)
7289 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7290 let metadata = file
7291 .metadata()
7292 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7293 if !metadata.is_file() {
7294 bail!("tracked path {} is not a regular file", path.display());
7295 }
7296 Ok(file)
7297}
7298
7299fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7300 if !same_file(left, right)
7301 || left.len() != right.len()
7302 || left.modified().ok() != right.modified().ok()
7303 || left.permissions() != right.permissions()
7304 {
7305 return false;
7306 }
7307 #[cfg(unix)]
7308 {
7309 use std::os::unix::fs::MetadataExt;
7310 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7311 }
7312 #[cfg(not(unix))]
7313 {
7314 left.created().ok() == right.created().ok()
7315 }
7316}
7317
7318const ATTRIBUTE_BATCH: usize = 1000;
7325
7326fn check_attributes(
7327 cwd: &Path,
7328 paths: impl IntoIterator<Item = PathBuf>,
7329) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7330 let paths = paths.into_iter().collect::<BTreeSet<_>>();
7331 if paths.is_empty() {
7332 return Ok(BTreeMap::new());
7333 }
7334 let ordered = paths.into_iter().collect::<Vec<_>>();
7335 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7336 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7337 values.extend(check_attribute_batch(cwd, batch)?);
7338 }
7339 Ok(values)
7340}
7341
7342fn check_attribute_batch(
7343 cwd: &Path,
7344 batch: &[PathBuf],
7345) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7346 const NAMES: [&str; 6] = [
7347 "filter",
7348 "working-tree-encoding",
7349 "ident",
7350 "text",
7351 "eol",
7352 "crlf",
7353 ];
7354 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7355 let mut input = String::new();
7356 for path in &paths {
7357 let path = path.to_str().ok_or_else(|| {
7358 spar_err!(
7359 "cannot inspect attributes for a non-UTF-8 path in {}",
7360 cwd.display()
7361 )
7362 })?;
7363 input.push_str(path);
7364 input.push('\0');
7365 }
7366 let argv = git_without_automation_argv(&[
7367 "check-attr",
7368 "-z",
7369 "--cached",
7370 "--stdin",
7371 "filter",
7372 "working-tree-encoding",
7373 "ident",
7374 "text",
7375 "eol",
7376 "crlf",
7377 ]);
7378 let output = proc::run_bytes(
7379 &argv,
7380 &ExecOpts::new()
7381 .cwd(cwd)
7382 .timeout_secs(30)
7383 .stdin(input)
7384 .stop_descendants(true),
7385 )?;
7386 if !output.is_empty() && !output.ends_with(&[0]) {
7387 bail!(
7388 "git returned an unterminated attribute result for {}",
7389 cwd.display()
7390 );
7391 }
7392 let fields = output
7393 .split(|byte| *byte == 0)
7394 .filter(|field| !field.is_empty())
7395 .collect::<Vec<_>>();
7396 if fields.len() != paths.len() * NAMES.len() * 3 {
7397 bail!(
7398 "git returned an unexpected attribute result for {}",
7399 cwd.display()
7400 );
7401 }
7402 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7403 for record in fields.chunks_exact(3) {
7404 let path = safe_git_path(record[0], "attribute")?;
7405 if !paths.contains(&path) {
7406 bail!(
7407 "git returned attributes for the wrong path in {}",
7408 cwd.display()
7409 );
7410 }
7411 let name = std::str::from_utf8(record[1])
7412 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7413 let value = std::str::from_utf8(record[2])
7414 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7415 values
7416 .entry(path)
7417 .or_default()
7418 .insert(name.to_string(), value.to_string());
7419 }
7420 if paths.iter().any(|path| {
7421 values
7422 .get(path)
7423 .is_none_or(|attributes| attributes.len() != NAMES.len())
7424 }) {
7425 bail!(
7426 "git omitted an attribute result for a tracked path in {}",
7427 cwd.display()
7428 );
7429 }
7430 Ok(values)
7431}
7432
7433fn attribute_is_active(value: Option<&String>) -> bool {
7434 !matches!(
7435 value.map(String::as_str),
7436 None | Some("unspecified") | Some("unset")
7437 )
7438}
7439
7440fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7441 attribute_is_active(values.get("filter"))
7442 || attribute_is_active(values.get("working-tree-encoding"))
7443}
7444
7445struct CheckoutConfig {
7454 autocrlf: Option<String>,
7455 eol: Option<String>,
7456 symlinks: Option<bool>,
7457}
7458
7459impl CheckoutConfig {
7460 fn read(cwd: &Path) -> Result<Self> {
7461 Ok(Self {
7462 autocrlf: git_config_value(cwd, "core.autocrlf")?,
7463 eol: git_config_value(cwd, "core.eol")?,
7464 symlinks: git_config_bool(cwd, "core.symlinks")?,
7465 })
7466 }
7467
7468 fn autocrlf_is_true(&self) -> bool {
7469 self.autocrlf.as_ref().is_some_and(|value| {
7470 matches!(
7471 value.to_ascii_lowercase().as_str(),
7472 "true" | "yes" | "on" | "1"
7473 )
7474 })
7475 }
7476
7477 fn eol_is(&self, wanted: &str) -> bool {
7478 self.eol
7479 .as_ref()
7480 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7481 }
7482}
7483
7484fn path_has_ambiguous_transform(
7485 config: &CheckoutConfig,
7486 values: &BTreeMap<String, String>,
7487) -> Result<bool> {
7488 if path_has_external_transform(values)
7489 || attribute_is_active(values.get("ident"))
7490 || attribute_is_active(values.get("crlf"))
7491 {
7492 return Ok(true);
7493 }
7494 let text = values.get("text").map(String::as_str);
7495 let eol = values.get("eol").map(String::as_str);
7496 if text == Some("auto") {
7497 return Ok(true);
7498 }
7499 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7500 || !matches!(
7501 eol,
7502 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7503 )
7504 {
7505 return Ok(true);
7506 }
7507 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7508 return Ok(config.autocrlf_is_true());
7509 }
7510 Ok(false)
7511}
7512
7513fn allows_expected_crlf(
7514 config: &CheckoutConfig,
7515 values: &BTreeMap<String, String>,
7516) -> Result<bool> {
7517 if path_has_external_transform(values)
7518 || attribute_is_active(values.get("ident"))
7519 || attribute_is_active(values.get("crlf"))
7520 {
7521 return Ok(false);
7522 }
7523 let text = values.get("text").map(String::as_str);
7524 let eol = values.get("eol").map(String::as_str);
7525 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7526 return Ok(false);
7527 }
7528 if eol == Some("crlf") {
7529 return Ok(true);
7530 }
7531 if text != Some("set") {
7532 return Ok(false);
7533 }
7534 if let Some(autocrlf) = config.autocrlf.as_deref() {
7535 match autocrlf.to_ascii_lowercase().as_str() {
7536 "true" | "yes" | "on" | "1" => return Ok(true),
7537 "input" => return Ok(false),
7538 _ => {}
7539 }
7540 }
7541 if config.eol_is("crlf") {
7542 return Ok(true);
7543 }
7544 #[cfg(windows)]
7545 if config.eol.is_none() || config.eol_is("native") {
7546 return Ok(true);
7547 }
7548 Ok(false)
7549}
7550
7551fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7552 let argv = git_without_automation_argv(&["config", "--get", key]);
7553 let output = proc::exec(
7554 &argv,
7555 &ExecOpts::new()
7556 .cwd(cwd)
7557 .timeout_secs(30)
7558 .check(false)
7559 .stop_descendants(true),
7560 )?;
7561 match output.code {
7562 0 => Ok(Some(output.stdout.trim().to_string())),
7563 1 => Ok(None),
7564 _ => bail!(
7565 "could not read Git configuration in {}: {}",
7566 cwd.display(),
7567 output.stderr.trim()
7568 ),
7569 }
7570}
7571
7572fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7573 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7574 let output = proc::exec(
7575 &argv,
7576 &ExecOpts::new()
7577 .cwd(cwd)
7578 .timeout_secs(30)
7579 .check(false)
7580 .stop_descendants(true),
7581 )?;
7582 match output.code {
7583 0 if output.stdout.trim() == "true" => Ok(Some(true)),
7584 0 if output.stdout.trim() == "false" => Ok(Some(false)),
7585 0 => bail!(
7586 "git returned an invalid boolean for {key} in {}",
7587 cwd.display()
7588 ),
7589 1 => Ok(None),
7590 _ => bail!(
7591 "could not read Git configuration in {}: {}",
7592 cwd.display(),
7593 output.stderr.trim()
7594 ),
7595 }
7596}
7597
7598#[cfg(unix)]
7599fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7600 use std::os::unix::fs::PermissionsExt;
7601 if metadata.permissions().mode() & 0o111 == 0 {
7602 "100644".to_string()
7603 } else {
7604 "100755".to_string()
7605 }
7606}
7607
7608#[cfg(not(unix))]
7609fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7610 "100644".to_string()
7611}
7612
7613fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7614 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7615 if !listed.is_empty() && !listed.ends_with(&[0]) {
7616 bail!(
7617 "git returned an unterminated tree listing for {}",
7618 cwd.display()
7619 );
7620 }
7621 let mut entries = Vec::new();
7622 for record in listed
7623 .split(|byte| *byte == 0)
7624 .filter(|record| !record.is_empty())
7625 {
7626 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7627 bail!("git returned a malformed tree record for {}", cwd.display());
7628 };
7629 let fields = record[..tab]
7630 .split(|byte| *byte == b' ')
7631 .collect::<Vec<_>>();
7632 if fields.len() != 3 {
7633 bail!("git returned a malformed tree header for {}", cwd.display());
7634 }
7635 let mode = std::str::from_utf8(fields[0])
7636 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7637 .to_string();
7638 let oid = std::str::from_utf8(fields[2])
7639 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7640 .to_string();
7641 entries.push(IndexEntry {
7642 path: safe_git_path(&record[tab + 1..], "tree")?,
7643 mode,
7644 oid,
7645 });
7646 }
7647 Ok(entries)
7648}
7649
7650fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7651 Ok(tree_entries(cwd, "HEAD")?
7652 .into_iter()
7653 .filter(|entry| entry.mode == "160000")
7654 .map(|entry| (entry.path, entry.oid))
7655 .collect())
7656}
7657
7658fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7659 let head = head_gitlinks(cwd)?;
7660 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7661 .into_iter()
7662 .map(|link| (link.path, link.oid))
7663 .collect();
7664 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7665 paths.extend(index.keys().cloned());
7666 Ok(paths
7667 .into_iter()
7668 .filter(|path| head.get(path) != index.get(path))
7669 .collect())
7670}
7671
7672fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7673 let path = parent.join(relative);
7674 let metadata = match std::fs::symlink_metadata(&path) {
7675 Ok(metadata) => metadata,
7676 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7677 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7678 };
7679 if !metadata.is_dir() {
7680 bail!("the gitlink at {} is not a directory", path.display());
7681 }
7682 let canonical = std::fs::canonicalize(&path)
7683 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7684 if canonical != path {
7685 bail!(
7686 "the gitlink at {} resolves through a symlink",
7687 path.display()
7688 );
7689 }
7690 if !path.join(".git").exists() {
7691 let empty = std::fs::read_dir(&path)
7692 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7693 .next()
7694 .is_none();
7695 if empty {
7696 return Ok(None);
7697 }
7698 bail!(
7699 "the uninitialized gitlink at {} contains local files",
7700 path.display()
7701 );
7702 }
7703 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7704 if inside.trim() != "true" {
7705 bail!("the gitlink at {} is not a worktree", path.display());
7706 }
7707 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7708 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7709 spar_err!(
7710 "could not resolve the gitlink top level at {}: {e}",
7711 path.display()
7712 )
7713 })?;
7714 if top != canonical {
7715 bail!(
7716 "the gitlink at {} belongs to a different worktree",
7717 path.display()
7718 );
7719 }
7720 Ok(Some(canonical))
7721}
7722
7723fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
7737 let root = std::fs::canonicalize(cwd)
7738 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7739 let mut allowed = BTreeSet::from([root.join(".git")]);
7740 let mut checkouts = BTreeSet::new();
7741 let mut repositories = vec![root.clone()];
7742 let mut visited = BTreeSet::new();
7743 while let Some(repository) = repositories.pop() {
7744 let canonical = std::fs::canonicalize(&repository)
7745 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7746 if !visited.insert(canonical.clone()) {
7747 bail!("submodule recursion revisited {}", canonical.display());
7748 }
7749 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
7750 let (path, nested) = untracked_record(&raw, "untracked")?;
7751 if nested {
7752 checkouts.insert(canonical.join(path));
7753 }
7754 }
7755 for link in gitlinks(&canonical)? {
7756 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
7757 continue;
7758 };
7759 allowed.insert(submodule.join(".git"));
7760 repositories.push(submodule);
7761 }
7762 }
7763
7764 let scan_root = root.clone();
7768 let mut directories = vec![root];
7769 while let Some(directory) = directories.pop() {
7770 let entries = match std::fs::read_dir(&directory) {
7771 Ok(entries) => entries,
7772 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7773 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7774 };
7775 for entry in entries {
7776 let entry = match entry {
7777 Ok(entry) => entry,
7778 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7779 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7780 };
7781 let path = entry.path();
7782 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
7783 continue;
7784 }
7785 if entry.file_name() == OsStr::new(".git") {
7786 if !allowed.contains(&path) {
7787 return Ok(Some(path));
7788 }
7789 continue;
7790 }
7791 let kind = match entry.file_type() {
7792 Ok(kind) => kind,
7793 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7794 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7795 };
7796 if kind.is_dir() && !checkouts.contains(&path) {
7797 directories.push(path);
7798 }
7799 }
7800 }
7801 Ok(None)
7802}
7803
7804pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
7805 if let Some(path) = unexpected_nested_git_entry(cwd)? {
7806 bail!(
7807 "the worktree contains a Git entry at {} that Git does not report as a nested \
7808 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
7809 prove they are unchanged.",
7810 path.display()
7811 );
7812 }
7813 let root = std::fs::canonicalize(cwd)
7814 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7815 let mut repositories = BTreeMap::new();
7816 let mut visited = BTreeSet::new();
7817 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7818 Ok(GitState { repositories })
7819}
7820
7821fn collect_git_state(
7822 repository: &Path,
7823 prefix: &Path,
7824 visited: &mut BTreeSet<PathBuf>,
7825 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7826) -> Result<()> {
7827 let canonical = std::fs::canonicalize(repository)
7828 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7829 if !visited.insert(canonical.clone()) {
7830 bail!("submodule recursion revisited {}", canonical.display());
7831 }
7832 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7833 let head = head.trim().to_string();
7834 if head.is_empty() {
7835 bail!("git returned an empty head for {}", repository.display());
7836 }
7837 let unsafe_index_flags = unsafe_index_flags(repository)?;
7838 let tracked = tracked_entries(repository)?;
7839 let gitlinks = gitlinks(repository)?;
7840 if repositories
7841 .insert(
7842 prefix.to_path_buf(),
7843 RepositoryState {
7844 head,
7845 unsafe_index_flags,
7846 tracked,
7847 gitlinks: gitlinks
7848 .iter()
7849 .map(|link| (link.path.clone(), link.oid.clone()))
7850 .collect(),
7851 },
7852 )
7853 .is_some()
7854 {
7855 bail!("Git state contains duplicate repository path {:?}", prefix);
7856 }
7857
7858 for link in gitlinks {
7859 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7860 continue;
7861 };
7862 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7863 }
7864 Ok(())
7865}
7866
7867fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7868 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7869 if !listed.is_empty() && !listed.ends_with(&[0]) {
7870 bail!(
7871 "git returned an unterminated index-flag listing for {}",
7872 cwd.display()
7873 );
7874 }
7875 let mut unsafe_records = Vec::new();
7876 for record in listed
7877 .split(|byte| *byte == 0)
7878 .filter(|record| !record.is_empty())
7879 {
7880 if record.len() < 3 || record[1] != b' ' {
7881 bail!(
7882 "git returned a malformed index-flag record for {}",
7883 cwd.display()
7884 );
7885 }
7886 if record[0] != b'H' {
7887 unsafe_records.extend_from_slice(record);
7888 unsafe_records.push(0);
7889 }
7890 }
7891 Ok(unsafe_records)
7892}
7893
7894pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7895 safe_git_state(cwd).map(|_| ())
7896}
7897
7898pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7899 let state = git_state(cwd)?;
7900 if let Some((path, _repository)) = state
7901 .repositories
7902 .iter()
7903 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7904 {
7905 let label = if path.as_os_str().is_empty() {
7906 cwd.to_path_buf()
7907 } else {
7908 cwd.join(path)
7909 };
7910 bail!(
7911 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7912 SPAR cannot prove the working files are unchanged, so it was kept.",
7913 label.display()
7914 );
7915 }
7916 Ok(state)
7917}
7918
7919fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7920 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7921}
7922
7923fn repository_has_recoverable_work_with(
7924 cwd: &Path,
7925 include_ignored: bool,
7926 disposable: Disposable,
7927) -> Result<bool> {
7928 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7929 return Ok(true);
7930 }
7931 let mut visited = BTreeSet::new();
7932 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7933}
7934
7935fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7936 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7937 let git_dir = PathBuf::from(git_dir.trim());
7938 let git_dir = if git_dir.is_absolute() {
7939 git_dir
7940 } else {
7941 cwd.join(git_dir)
7942 };
7943 let git_dir = std::fs::canonicalize(&git_dir)
7944 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
7945 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
7946 Ok(_) => return Ok(true),
7947 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7948 Err(error) => {
7949 return Err(spar_err!(
7950 "could not inspect per-worktree configuration in {}: {error}",
7951 git_dir.display()
7952 ))
7953 }
7954 }
7955
7956 let orig_head = git_dir.join("ORIG_HEAD");
7957 match std::fs::symlink_metadata(&orig_head) {
7958 Ok(metadata) if metadata.is_file() => {
7959 let oid = std::fs::read_to_string(&orig_head)
7960 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
7961 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
7962 return Ok(true);
7963 };
7964 if !commit_has_shared_ref(cwd, &commit)? {
7965 return Ok(true);
7966 }
7967 }
7968 Ok(_) => return Ok(true),
7969 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7970 Err(error) => {
7971 return Err(spar_err!(
7972 "could not inspect {}: {error}",
7973 orig_head.display()
7974 ))
7975 }
7976 }
7977
7978 let edit_message = git_dir.join("COMMIT_EDITMSG");
7979 match std::fs::symlink_metadata(&edit_message) {
7980 Ok(metadata) if metadata.is_file() => {
7981 let draft = std::fs::read(&edit_message)
7982 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
7983 if draft != head_commit_message(cwd)? {
7984 return Ok(true);
7985 }
7986 }
7987 Ok(_) => return Ok(true),
7988 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7989 Err(error) => {
7990 return Err(spar_err!(
7991 "could not inspect {}: {error}",
7992 edit_message.display()
7993 ))
7994 }
7995 }
7996
7997 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
7998 return Ok(true);
7999 }
8000
8001 let local_refs = run_git_bytes(
8002 cwd,
8003 &[
8004 "for-each-ref",
8005 "--format=%(refname)",
8006 "refs/worktree",
8007 "refs/bisect",
8008 "refs/rewritten",
8009 ],
8010 )?;
8011 if !local_refs.is_empty() {
8012 return Ok(true);
8013 }
8014
8015 for entry in std::fs::read_dir(&git_dir)
8019 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8020 {
8021 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8022 let known = matches!(
8023 entry.file_name().to_str(),
8024 Some(
8025 "HEAD"
8026 | "ORIG_HEAD"
8027 | "COMMIT_EDITMSG"
8028 | "FETCH_HEAD"
8029 | "commondir"
8030 | "gitdir"
8031 | "index"
8032 | "logs"
8033 | "refs"
8034 )
8035 );
8036 if !known {
8037 return Ok(true);
8038 }
8039 }
8040
8041 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8042 if !commit_has_shared_ref(cwd, head.trim())? {
8043 return Ok(true);
8044 }
8045 Ok(false)
8046}
8047
8048fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8049 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8050 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8051 bail!(
8052 "git returned a commit without a message separator in {}",
8053 cwd.display()
8054 );
8055 };
8056 Ok(commit[split + 2..].to_vec())
8057}
8058
8059fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8060 let metadata = match std::fs::symlink_metadata(logs) {
8061 Ok(metadata) => metadata,
8062 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8063 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8064 };
8065 if !metadata.is_dir() {
8066 return Ok(true);
8067 }
8068 let mut files = Vec::new();
8069 let mut directories = vec![logs.to_path_buf()];
8070 while let Some(directory) = directories.pop() {
8071 for entry in std::fs::read_dir(&directory)
8072 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8073 {
8074 let entry =
8075 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8076 let path = entry.path();
8077 let kind = entry
8078 .file_type()
8079 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8080 if kind.is_dir() {
8081 directories.push(path);
8082 } else if kind.is_file() {
8083 files.push(path);
8084 } else {
8085 return Ok(true);
8086 }
8087 }
8088 }
8089
8090 let mut commits = BTreeSet::new();
8091 for path in files {
8092 if !collect_reflog_commits(cwd, &path, &mut commits)? {
8093 return Ok(true);
8094 }
8095 }
8096 for commit in commits {
8097 if !commit_has_shared_ref(cwd, &commit)? {
8098 return Ok(true);
8099 }
8100 }
8101 Ok(false)
8102}
8103
8104fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8108 let common = common_git_dir(cwd)?;
8109 let reflog = common.join("logs").join(refname);
8110 let metadata = match std::fs::symlink_metadata(&reflog) {
8111 Ok(metadata) => metadata,
8112 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8113 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8114 };
8115 if !metadata.is_file() {
8116 return Ok(false);
8117 }
8118 let mut commits = BTreeSet::new();
8119 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8120 return Ok(false);
8121 }
8122 for commit in commits {
8123 if is_ancestor(cwd, &commit, durable_tip)?
8124 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8125 {
8126 continue;
8127 }
8128 return Ok(false);
8129 }
8130 Ok(true)
8131}
8132
8133fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8134 let file = std::fs::File::open(path)
8135 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8136 for line in std::io::BufReader::new(file).lines() {
8137 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8138 let mut fields = line.splitn(3, ' ');
8139 let Some(old) = fields.next() else {
8140 return Ok(false);
8141 };
8142 let Some(new) = fields.next() else {
8143 return Ok(false);
8144 };
8145 if fields.next().is_none() {
8146 return Ok(false);
8147 }
8148 for oid in [old, new] {
8149 if oid.bytes().all(|byte| byte == b'0') {
8150 continue;
8151 }
8152 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8153 return Ok(false);
8154 };
8155 commits.insert(commit);
8156 }
8157 }
8158 Ok(true)
8159}
8160
8161fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8162 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8163 let path = PathBuf::from(raw.trim());
8164 let path = if path.is_absolute() {
8165 path
8166 } else {
8167 cwd.join(path)
8168 };
8169 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8170}
8171
8172fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8173 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8174 let output = proc::exec(
8175 &argv,
8176 &ExecOpts::new()
8177 .cwd(cwd)
8178 .timeout_secs(30)
8179 .check(false)
8180 .stop_descendants(true),
8181 )?;
8182 match output.code {
8183 0 => Ok(true),
8184 1 => Ok(false),
8185 _ => bail!("{}", proc::failure_message(&argv, &output)),
8186 }
8187}
8188
8189fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8190 let commit = format!("{oid}^{{commit}}");
8191 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8192 let output = proc::exec(
8193 &argv,
8194 &ExecOpts::new()
8195 .cwd(cwd)
8196 .timeout_secs(30)
8197 .check(false)
8198 .stop_descendants(true),
8199 )?;
8200 if output.code != 0 {
8201 return Ok(None);
8202 }
8203 let oid = output.stdout.trim();
8204 if oid.is_empty() {
8205 return Ok(None);
8206 }
8207 Ok(Some(oid.to_string()))
8208}
8209
8210fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8211 commit_has_shared_ref_except(cwd, oid, None)
8212}
8213
8214fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8215 let contains = format!("--contains={oid}");
8216 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8217 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8218 !record.is_empty()
8219 && !record.starts_with(b"refs/worktree/")
8220 && !record.starts_with(b"refs/bisect/")
8221 && !record.starts_with(b"refs/rewritten/")
8222 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8223 }))
8224}
8225
8226fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8241 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8242 for raw in &ordinary {
8243 let (path, _) = untracked_record(raw, "untracked")?;
8244 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8245 continue;
8246 }
8247 return Ok(true);
8248 }
8249 let listed = untracked_listing(
8250 cwd,
8251 &[
8252 "ls-files",
8253 "--others",
8254 "--ignored",
8255 "--exclude-standard",
8256 "-z",
8257 ],
8258 )?;
8259 for raw in listed {
8260 let (path, nested) = untracked_record(&raw, "ignored")?;
8261 if nested {
8265 return Ok(true);
8266 }
8267 if disposable == Disposable::BuildOutput
8268 && !is_generated_artifact(&path)
8269 && !is_desktop_metadata(&path)
8270 {
8271 return Ok(true);
8272 }
8273 }
8274 Ok(false)
8275}
8276
8277fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8278 let listed = run_git_bytes(cwd, args)?;
8279 if !listed.is_empty() && !listed.ends_with(&[0]) {
8280 bail!(
8281 "git returned an unterminated untracked-file list for {}",
8282 cwd.display()
8283 );
8284 }
8285 Ok(listed
8286 .split(|byte| *byte == 0)
8287 .filter(|raw| !raw.is_empty())
8288 .map(|raw| raw.to_vec())
8289 .collect())
8290}
8291
8292fn repository_has_recoverable_work_inner(
8293 cwd: &Path,
8294 include_ignored: bool,
8295 disposable: Disposable,
8296 visited: &mut BTreeSet<PathBuf>,
8297) -> Result<bool> {
8298 let canonical = std::fs::canonicalize(cwd)
8299 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8300 if !visited.insert(canonical.clone()) {
8301 bail!("submodule recursion revisited {}", canonical.display());
8302 }
8303 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8304 return Ok(true);
8305 }
8306 if !unsafe_index_flags(cwd)?.is_empty() {
8307 return Ok(true);
8308 }
8309 if attributes_may_be_modified(cwd)? {
8310 return Ok(true);
8311 }
8312 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8313 return Ok(true);
8314 }
8315 if include_ignored {
8316 let index = index_entries(cwd)?
8317 .into_iter()
8318 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8319 .collect::<BTreeMap<_, _>>();
8320 let head = tree_entries(cwd, "HEAD")?
8321 .into_iter()
8322 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8323 .collect::<BTreeMap<_, _>>();
8324 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8325 return Ok(true);
8326 }
8327 let tracked = tracked_entries(cwd)?;
8328 let effective = check_attributes(cwd, tracked.keys().cloned())?;
8329 let config = CheckoutConfig::read(cwd)?;
8330 for (path, entry) in tracked {
8331 let Some(worktree) = entry.worktree else {
8332 return Ok(true);
8333 };
8334 let attributes = effective.get(&path).ok_or_else(|| {
8335 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8336 })?;
8337 if path_has_ambiguous_transform(&config, attributes)? {
8338 return Ok(true);
8339 }
8340 let symlink_file = entry.index_mode == "120000"
8341 && worktree.mode == "100644"
8342 && worktree.raw_oid == entry.index_oid
8343 && config.symlinks == Some(false);
8344 if worktree.mode != entry.index_mode && !symlink_file {
8345 return Ok(true);
8346 }
8347 if entry.index_mode == "120000" {
8348 if worktree.raw_oid != entry.index_oid {
8349 return Ok(true);
8350 }
8351 continue;
8352 }
8353 #[cfg(unix)]
8354 {
8355 let expected = if entry.index_mode == "100755" {
8356 0o755
8357 } else {
8358 0o644
8359 };
8360 if worktree.permissions != expected {
8361 return Ok(true);
8362 }
8363 }
8364 if allows_expected_crlf(&config, attributes)? {
8365 let (normalized, every_lf_was_crlf) =
8366 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8367 if !every_lf_was_crlf || normalized != entry.index_oid {
8368 return Ok(true);
8369 }
8370 } else if worktree.raw_oid != entry.index_oid {
8371 return Ok(true);
8372 }
8373 }
8374 } else {
8375 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8376 if !run_git_bytes(cwd, &args)?.is_empty() {
8377 return Ok(true);
8378 }
8379 }
8380 for link in gitlinks(cwd)? {
8381 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8382 continue;
8383 };
8384 if include_ignored {
8389 return Ok(true);
8390 }
8391 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8392 if head.trim() != link.oid {
8393 return Ok(true);
8394 }
8395 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8396 {
8397 return Ok(true);
8398 }
8399 }
8400 Ok(false)
8401}
8402
8403pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8404 repository_has_recoverable_work(cwd, false)
8405}
8406
8407fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8408 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8409 Ok(!run_git_bytes(cwd, &args)?.is_empty())
8410}
8411
8412#[cfg(unix)]
8413fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8414 use std::os::unix::ffi::OsStringExt;
8415 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8416}
8417
8418#[cfg(not(unix))]
8419fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8420 String::from_utf8(raw.to_vec())
8421 .map(PathBuf::from)
8422 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8423}
8424
8425fn vanished_untracked_file() -> UntrackedFile {
8441 UntrackedFile {
8442 kind: 0,
8443 len: 0,
8444 modified: None,
8445 created: None,
8446 readonly: false,
8447 symlink_target: None,
8448 #[cfg(unix)]
8449 device: 0,
8450 #[cfg(unix)]
8451 inode: 0,
8452 #[cfg(unix)]
8453 mode: 0,
8454 #[cfg(unix)]
8455 change_seconds: 0,
8456 #[cfg(unix)]
8457 change_nanoseconds: 0,
8458 }
8459}
8460
8461fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8462 let metadata = match std::fs::symlink_metadata(path) {
8463 Ok(metadata) => metadata,
8464 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8465 Err(e) => {
8466 return Err(spar_err!(
8467 "could not inspect the nested repository at {}: {e}",
8468 path.display()
8469 ))
8470 }
8471 };
8472 if !metadata.is_dir() {
8473 bail!(
8474 "git reported {} as a nested repository, but it is not a directory",
8475 path.display()
8476 );
8477 }
8478 if std::fs::symlink_metadata(path.join(".git")).is_err() {
8479 bail!(
8480 "git reported {} as a nested repository, but it has no Git entry",
8481 path.display()
8482 );
8483 }
8484 #[cfg(unix)]
8485 {
8486 use std::os::unix::fs::MetadataExt;
8487 Ok(UntrackedFile {
8488 kind: 3,
8489 len: 0,
8490 modified: None,
8491 created: metadata.created().ok(),
8492 readonly: metadata.permissions().readonly(),
8493 symlink_target: None,
8494 device: metadata.dev(),
8495 inode: metadata.ino(),
8496 mode: metadata.mode(),
8497 change_seconds: 0,
8498 change_nanoseconds: 0,
8499 })
8500 }
8501 #[cfg(not(unix))]
8502 {
8503 Ok(UntrackedFile {
8504 kind: 3,
8505 len: 0,
8506 modified: None,
8507 created: metadata.created().ok(),
8508 readonly: metadata.permissions().readonly(),
8509 symlink_target: None,
8510 })
8511 }
8512}
8513
8514fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8515 let metadata = match std::fs::symlink_metadata(path) {
8516 Ok(metadata) => metadata,
8517 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8518 Err(e) => {
8519 return Err(spar_err!(
8520 "could not inspect untracked file {}: {e}",
8521 path.display()
8522 ))
8523 }
8524 };
8525 let kind = if metadata.file_type().is_symlink() {
8526 2
8527 } else if metadata.is_file() {
8528 1
8529 } else {
8530 bail!(
8531 "untracked path {} is not a regular file or symlink",
8532 path.display()
8533 );
8534 };
8535 let symlink_target = if kind == 2 {
8536 match std::fs::read_link(path) {
8537 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8538 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8539 return Ok(vanished_untracked_file())
8540 }
8541 Err(e) => {
8542 return Err(spar_err!(
8543 "could not read untracked symlink {}: {e}",
8544 path.display()
8545 ))
8546 }
8547 }
8548 } else {
8549 None
8550 };
8551 #[cfg(unix)]
8552 {
8553 use std::os::unix::fs::MetadataExt;
8554 Ok(UntrackedFile {
8555 kind,
8556 len: metadata.len(),
8557 modified: metadata.modified().ok(),
8558 created: metadata.created().ok(),
8559 readonly: metadata.permissions().readonly(),
8560 symlink_target,
8561 device: metadata.dev(),
8562 inode: metadata.ino(),
8563 mode: metadata.mode(),
8564 change_seconds: metadata.ctime(),
8565 change_nanoseconds: metadata.ctime_nsec(),
8566 })
8567 }
8568 #[cfg(not(unix))]
8569 {
8570 Ok(UntrackedFile {
8571 kind,
8572 len: metadata.len(),
8573 modified: metadata.modified().ok(),
8574 created: metadata.created().ok(),
8575 readonly: metadata.permissions().readonly(),
8576 symlink_target,
8577 })
8578 }
8579}
8580
8581#[cfg(unix)]
8582fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8583 use std::os::unix::ffi::OsStrExt;
8584 Ok(value.as_bytes().to_vec())
8585}
8586
8587#[cfg(not(unix))]
8588fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8589 value
8590 .to_str()
8591 .map(|value| value.as_bytes().to_vec())
8592 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8593}
8594
8595#[cfg(unix)]
8596fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8597 use std::os::unix::fs::MetadataExt;
8598 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8599}
8600
8601#[cfg(not(unix))]
8602fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8603 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8604}
8605
8606#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8607pub struct BranchRecord {
8608 pub kind: String,
8609 pub number: i64,
8610}
8611
8612pub fn review_ref(number: i64) -> String {
8615 format!("refs/spar/pr-{number}")
8616}
8617
8618pub fn is_finished(state: &str) -> bool {
8619 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8620}
8621
8622pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8629 if let Some(parent) = path.parent() {
8630 std::fs::create_dir_all(parent)
8631 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8632 }
8633 let tmp = path.with_extension(format!(
8636 "{}.tmp",
8637 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8638 ));
8639 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8640 std::fs::rename(&tmp, path)
8641 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8642 Ok(())
8643}
8644
8645pub fn has_heading(text: &str, title: &str) -> bool {
8652 let wanted = title.trim().to_lowercase();
8653 if wanted.is_empty() {
8654 return false;
8655 }
8656 text.lines()
8657 .filter_map(|line| line.trim().strip_prefix("## "))
8658 .any(|heading| heading.trim().to_lowercase() == wanted)
8659}
8660
8661pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8664 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8665}
8666
8667#[derive(Deserialize)]
8669#[serde(rename_all = "camelCase")]
8670pub struct LinkedPrRow {
8671 number: i64,
8672 #[serde(default)]
8673 url: String,
8674 #[serde(default)]
8675 title: String,
8676 #[serde(default)]
8677 closing_issues_references: Vec<IssueRef>,
8678}
8679
8680pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8687 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8688}
8689
8690pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8692 rows.iter()
8693 .find(|row| {
8694 row.closing_issues_references
8695 .iter()
8696 .any(|linked| linked.number == issue)
8697 })
8698 .map(|row| PrRef {
8699 number: row.number,
8700 url: row.url.clone(),
8701 title: row.title.clone(),
8702 })
8703}
8704
8705pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8707 pick_linked_pr(&linked_pr_rows(json)?, issue)
8708}
8709
8710pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8717 if text.trim().is_empty() {
8718 return Err(spar_err!("GitHub returned no comment data"));
8719 }
8720 let mut out = Vec::new();
8721 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8722 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8723 Value::Array(items) => out.extend(items),
8724 _ => return Err(spar_err!("unexpected non-array comment page")),
8725 }
8726 }
8727 Ok(out)
8728}
8729
8730pub fn parse_comment_pages(text: &str) -> Vec<Value> {
8731 let mut out = Vec::new();
8732 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8733 match value {
8734 Ok(Value::Array(items)) => out.extend(items),
8735 Ok(other) => out.push(other),
8736 Err(_) => break,
8737 }
8738 }
8739 out
8740}
8741
8742pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
8750 body.trim_start().starts_with(STATE_MARKER)
8751 && !viewer.trim().is_empty()
8752 && author.trim().eq_ignore_ascii_case(viewer.trim())
8753 && parse_state_comment(body).is_some()
8754}
8755
8756pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
8759 let marker = body.find(STATE_MARKER)?;
8760 let start = body[marker..].find('{')? + marker;
8761 let end = body.rfind('}')?;
8762 if end <= start {
8763 return None;
8764 }
8765 match serde_json::from_str(&body[start..=end]) {
8766 Ok(state) => Some(state),
8767 Err(_) => {
8768 logdim!("found a spar state comment but could not parse it");
8769 None
8770 }
8771 }
8772}
8773
8774fn choose_state_for_head(
8775 candidates: Vec<PersistedState>,
8776 actual_head: &str,
8777) -> Option<PersistedState> {
8778 let matching: Vec<PersistedState> = candidates
8779 .iter()
8780 .filter(|state| state.pr_head == actual_head)
8781 .cloned()
8782 .collect();
8783 if !matching.is_empty() {
8784 return newest_state(matching);
8785 }
8786 newest_state(candidates)
8787}
8788
8789fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
8790 candidates.into_iter().reduce(|best, candidate| {
8791 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
8792 candidate
8793 } else {
8794 best
8799 }
8800 })
8801}
8802
8803pub fn self_binary() -> Result<PathBuf> {
8809 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
8810 let path = PathBuf::from(path);
8811 if proc::is_executable(&path) {
8812 return Ok(path);
8813 }
8814 bail!(
8815 "SPAR_SELF_BIN is set to {}, which is not executable",
8816 path.display()
8817 );
8818 }
8819 std::env::current_exe()
8820 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8821}
8822
8823fn bool_env(value: bool) -> &'static str {
8824 if value {
8825 "1"
8826 } else {
8827 "0"
8828 }
8829}
8830
8831pub fn sh_quote(text: &str) -> String {
8834 format!("'{}'", text.replace('\'', r"'\''"))
8835}
8836
8837pub fn style_from_env() -> Style {
8840 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8841 Style {
8842 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8843 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8844 ..Style::permissive()
8845 }
8846}
8847
8848#[cfg(test)]
8849mod tests {
8850 use super::*;
8851 use crate::config::StateStore;
8852 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8853 use std::process::Command;
8854
8855 fn repo_for_titles() -> Repo {
8856 Repo {
8857 root: PathBuf::from("/nonexistent"),
8858 style: Style::default(),
8859 branch_prefix: String::new(),
8860 state_store: StateStore::Local,
8861 followups: crate::config::Followups::Issues,
8862 drafts: Drafts::Never,
8863 fork_when_denied: true,
8864 setup: CheckCommand::default(),
8865 fork: OnceLock::new(),
8866 viewer: OnceLock::new(),
8867 checkpoints: Mutex::new(BTreeMap::new()),
8868 writes: WriteStats::default(),
8869 }
8870 }
8871
8872 #[test]
8873 fn write_results_accumulate_for_the_run() {
8874 let repo = repo_for_titles();
8875
8876 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8877 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8878
8879 assert_eq!(
8880 WriteSummary {
8881 attempted: 2,
8882 failed: 1,
8883 },
8884 repo.write_summary()
8885 );
8886 }
8887
8888 #[test]
8889 fn only_failed_write_preflights_join_the_summary() {
8890 let repo = repo_for_titles();
8891
8892 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8893 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8894
8895 assert_eq!(
8896 WriteSummary {
8897 attempted: 1,
8898 failed: 1,
8899 },
8900 repo.write_summary()
8901 );
8902 }
8903
8904 #[test]
8905 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8906 let repo = repo_for_titles();
8907
8908 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8909 assert_eq!(
8910 WriteSummary {
8911 attempted: 1,
8912 failed: 1,
8913 },
8914 repo.write_summary()
8915 );
8916 }
8917
8918 #[test]
8919 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8920 let mut repo = repo_for_titles();
8921 repo.followups = Followups::Local;
8922
8923 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8924 assert_eq!(WriteSummary::default(), repo.write_summary());
8925 }
8926
8927 #[test]
8928 fn a_failed_remote_state_read_stops_before_state_mutation() {
8929 let root = std::env::temp_dir().join(format!(
8930 "spar-state-preflight-{}-{}",
8931 std::process::id(),
8932 std::time::SystemTime::now()
8933 .duration_since(std::time::UNIX_EPOCH)
8934 .unwrap()
8935 .as_nanos()
8936 ));
8937 std::fs::create_dir_all(&root).unwrap();
8938 let _fixture = ReviewFixture { root: root.clone() };
8939 let mut repo = repo_for_titles();
8940 repo.root = root;
8941 repo.state_store = StateStore::Both;
8942 let state = PersistedState {
8943 version: 1,
8944 checkpoint: 4,
8945 round: 2,
8946 next_actor: "a".into(),
8947 status: Status::Pending,
8948 pr_head: "abc123".into(),
8949 ledger: Ledger::new(),
8950 filed: Vec::new(),
8951 open_findings: Vec::new(),
8952 disputes: Vec::new(),
8953 noted: Vec::new(),
8954 };
8955
8956 let error = repo
8957 .write_state_after_remote_read(
8958 7,
8959 &state,
8960 Err(crate::error::SparError::new("state comments unavailable")),
8961 )
8962 .unwrap_err();
8963
8964 assert!(error.to_string().contains("state comments unavailable"));
8965 assert!(!repo.state_path(7).exists());
8966 assert_eq!(0, repo.remembered_checkpoint(7));
8967 assert_eq!(
8968 WriteSummary {
8969 attempted: 1,
8970 failed: 1,
8971 },
8972 repo.write_summary()
8973 );
8974 }
8975
8976 #[test]
8977 fn only_known_build_and_cache_directories_are_generated_artifacts() {
8978 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
8979 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
8980 assert!(is_generated_artifact(Path::new(
8981 "package/node_modules/dependency/file.js"
8982 )));
8983 assert!(!is_generated_artifact(Path::new(
8984 "distribution/required-package.js"
8985 )));
8986 assert!(!is_generated_artifact(Path::new(
8987 "generated/required-fixture.txt"
8988 )));
8989 assert!(!is_generated_artifact(Path::new("local.env")));
8990 }
8991
8992 #[test]
8993 fn only_finder_state_files_are_desktop_metadata() {
8994 assert!(is_desktop_metadata(Path::new(".DS_Store")));
8995 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
8996 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
8997 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
8998 }
8999
9000 struct ReviewFixture {
9001 root: PathBuf,
9002 }
9003
9004 impl Drop for ReviewFixture {
9005 fn drop(&mut self) {
9006 let _ = std::fs::remove_dir_all(&self.root);
9007 }
9008 }
9009
9010 fn test_git(cwd: &Path, args: &[&str]) -> String {
9011 let output = Command::new("git")
9012 .args(args)
9013 .current_dir(cwd)
9014 .output()
9015 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9016 assert!(
9017 output.status.success(),
9018 "git {args:?} failed: {}",
9019 String::from_utf8_lossy(&output.stderr)
9020 );
9021 String::from_utf8_lossy(&output.stdout).into_owned()
9022 }
9023
9024 #[test]
9028 fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9029 let root =
9030 std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9031 let _cleanup = ReviewFixture { root: root.clone() };
9032 let origin = root.join("origin.git");
9033 let work = root.join("work");
9034 let marker = root.join("textconv-ran");
9035 std::fs::create_dir_all(&origin).unwrap();
9036 std::fs::create_dir_all(&work).unwrap();
9037 test_git(&origin, &["init", "--bare", "-b", "main"]);
9038 test_git(&work, &["init", "-b", "main"]);
9039 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9040 test_git(&work, &["config", "user.name", "spar test"]);
9041 test_git(&work, &["config", "commit.gpgsign", "false"]);
9042 test_git(&work, &["config", "diff.noprefix", "true"]);
9043 test_git(&work, &["config", "diff.context", "0"]);
9044 test_git(&work, &["config", "diff.interHunkContext", "10"]);
9045 test_git(&work, &["config", "diff.algorithm", "patience"]);
9046 test_git(&work, &["config", "color.diff", "always"]);
9047 test_git(
9048 &work,
9049 &[
9050 "config",
9051 "diff.conv.textconv",
9052 &format!("touch {}; cat", marker.display()),
9053 ],
9054 );
9055 std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9056 let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9057 std::fs::write(work.join("src.txt"), &lines).unwrap();
9058 test_git(&work, &["add", "."]);
9059 test_git(&work, &["commit", "-m", "seed"]);
9060 test_git(
9061 &work,
9062 &["remote", "add", "origin", origin.to_str().unwrap()],
9063 );
9064 test_git(&work, &["push", "-u", "origin", "main"]);
9065 test_git(&work, &["checkout", "-b", "feature"]);
9066 std::fs::write(
9067 work.join("src.txt"),
9068 lines
9069 .replace("line 5\n", "changed 5\n")
9070 .replace("line 15\n", "changed 15\n"),
9071 )
9072 .unwrap();
9073 std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9074 std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9075 test_git(&work, &["add", "."]);
9076 test_git(&work, &["commit", "-m", "change"]);
9077 let cfg = crate::config::parse(
9078 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9079 )
9080 .unwrap();
9081 let repo = Repo::open(&work, &cfg).unwrap();
9082 assert_eq!(
9083 Hunks::from([
9084 ("new.txt".to_string(), vec![(1, 2)]),
9085 ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9086 ("with space.txt".to_string(), vec![(1, 1)]),
9087 ]),
9088 repo.diff_hunks(&work, "main")
9089 );
9090 assert!(!marker.exists(), "the text converter ran");
9091 test_git(&work, &["diff", "origin/main...HEAD"]);
9093 assert!(marker.exists(), "the fixture's converter never runs at all");
9094 }
9095
9096 fn review_fixture(
9097 tag: &str,
9098 number: i64,
9099 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9100 use std::sync::atomic::{AtomicU32, Ordering};
9101 static NEXT: AtomicU32 = AtomicU32::new(0);
9102 let id = NEXT.fetch_add(1, Ordering::Relaxed);
9103 let root =
9104 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9105 let origin = root.join("origin.git");
9106 let work = root.join("work");
9107 std::fs::create_dir_all(&origin).unwrap();
9108 std::fs::create_dir_all(&work).unwrap();
9109 test_git(&origin, &["init", "--bare", "-b", "main"]);
9110 test_git(&work, &["init", "-b", "main"]);
9111 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9112 test_git(&work, &["config", "user.name", "spar test"]);
9113 test_git(&work, &["config", "commit.gpgsign", "false"]);
9114 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9115 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9116 std::fs::write(work.join("README.md"), "seed\n").unwrap();
9117 std::fs::write(work.join("data.txt"), "old\n").unwrap();
9118 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9119 std::fs::write(work.join(".gitattributes"), "* text\n").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(
9128 &work,
9129 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9130 );
9131 let cfg = crate::config::parse(
9132 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9133 )
9134 .unwrap();
9135 let repo = Repo::open(&work, &cfg).unwrap();
9136 let path = repo.worktree_for_pr_head(number).unwrap();
9137 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9138 (ReviewFixture { root }, repo, path, checkpoint)
9139 }
9140
9141 #[test]
9142 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9143 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9144
9145 repo.release_review_worktree_checked(901, &checkpoint)
9146 .unwrap();
9147
9148 assert!(!path.exists());
9149 }
9150
9151 #[test]
9152 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9153 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9154 let work = fixture.root.join("work");
9155 let seed = test_git(&work, &["rev-parse", "HEAD"]);
9156 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9157 let publish = |message: &str| {
9160 let oid = test_git(
9161 &work,
9162 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9163 );
9164 let oid = oid.trim().to_string();
9165 test_git(
9166 &work,
9167 &[
9168 "push",
9169 "--force",
9170 "origin",
9171 &format!("{oid}:refs/pull/923/head"),
9172 ],
9173 );
9174 oid
9175 };
9176 let reviewed = publish("reviewed head");
9177 repo.worktree_for_pr_head(923).unwrap();
9178 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9179
9180 let rewritten = publish("rewritten head");
9181 repo.worktree_for_pr_head(923).unwrap();
9182
9183 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9184 }
9185
9186 #[test]
9187 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9188 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9189 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9190 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9191 test_git(&path, &["add", "recovery.txt"]);
9192 test_git(&path, &["commit", "-m", "recovery commit"]);
9193 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9194 test_git(&path, &["reset", "--hard", "main"]);
9195
9196 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9197 test_git(
9198 &path,
9199 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9200 );
9201 }
9202
9203 #[test]
9204 fn a_review_ref_reflog_only_commit_prevents_deletion() {
9205 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9206 let local_ref = review_ref(921);
9207 let original = test_git(&path, &["rev-parse", &local_ref]);
9208 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9209 let recovery = test_git(
9210 &path,
9211 &[
9212 "commit-tree",
9213 tree.trim(),
9214 "-p",
9215 original.trim(),
9216 "-m",
9217 "review ref recovery",
9218 ],
9219 );
9220 test_git(
9221 &path,
9222 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9223 );
9224 test_git(
9225 &path,
9226 &["update-ref", &local_ref, original.trim(), recovery.trim()],
9227 );
9228
9229 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9230 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9231 test_git(
9232 &path,
9233 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9234 );
9235 }
9236
9237 #[test]
9238 fn an_unpublished_commit_message_draft_is_recoverable() {
9239 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9240 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9241 let git_dir = if raw.is_absolute() {
9242 raw
9243 } else {
9244 path.join(raw)
9245 };
9246 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9247
9248 assert!(repository_has_recoverable_work(&path, true).unwrap());
9249 assert_eq!(
9250 "unique recovery draft\n",
9251 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9252 );
9253 }
9254
9255 #[test]
9256 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9257 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9258 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9259
9260 let error = repo
9261 .release_review_worktree_checked(902, &checkpoint)
9262 .unwrap_err();
9263
9264 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9265 assert!(error.to_string().contains("kept for recovery"), "{error}");
9266 assert_eq!(
9267 "recover me\n",
9268 std::fs::read_to_string(path.join("README.md")).unwrap()
9269 );
9270 repo.release_review_worktree(902);
9271 }
9272
9273 #[test]
9274 fn a_review_commit_is_retained_after_a_checked_read() {
9275 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9276 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9277 test_git(&path, &["add", "review-note.txt"]);
9278 test_git(&path, &["commit", "-m", "local review recovery"]);
9279 let head = test_git(&path, &["rev-parse", "HEAD"]);
9280
9281 let error = repo
9282 .release_review_worktree_checked(903, &checkpoint)
9283 .unwrap_err();
9284
9285 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9286 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9287 assert_eq!(
9288 "recover me\n",
9289 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9290 );
9291 repo.release_review_worktree(903);
9292 }
9293
9294 #[test]
9298 fn an_ignored_review_file_is_retained_after_a_checked_read() {
9299 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9300 std::fs::create_dir_all(path.join("generated")).unwrap();
9301 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9302
9303 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9304 .expect("its own output is not a change");
9305 let error = repo
9306 .release_review_worktree_checked(904, &checkpoint)
9307 .unwrap_err();
9308
9309 assert!(error.to_string().contains("recoverable"), "{error}");
9310 assert_eq!(
9311 "recover me\n",
9312 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9313 );
9314 repo.release_review_worktree(904);
9315 }
9316
9317 #[test]
9318 fn a_preexisting_ignored_review_file_change_is_retained() {
9324 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9325 std::fs::create_dir_all(path.join("generated")).unwrap();
9326 let ignored = path.join("generated/recovery.txt");
9327 std::fs::write(&ignored, "before\n").unwrap();
9328 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9329 std::fs::write(&ignored, "after!\n").unwrap();
9330
9331 let error = repo
9332 .release_review_worktree_checked(905, &checkpoint)
9333 .unwrap_err();
9334
9335 assert!(error.to_string().contains("recoverable"), "{error}");
9336 assert!(path.exists());
9337 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9338 repo.release_review_worktree(905);
9339 }
9340
9341 #[test]
9342 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9343 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9344 std::fs::create_dir_all(path.join("generated")).unwrap();
9345 let ignored = path.join("generated/recovery.txt");
9346 std::fs::write(&ignored, "keep me\n").unwrap();
9347 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9348
9349 let error = repo
9350 .release_review_worktree_checked(906, &checkpoint)
9351 .unwrap_err();
9352
9353 assert!(error.to_string().contains("recoverable"), "{error}");
9354 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9355 }
9356
9357 #[test]
9358 fn overwriting_a_preexisting_untracked_file_is_detected() {
9359 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9360 let untracked = path.join("notes.txt");
9361 std::fs::write(&untracked, "before\n").unwrap();
9362 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9363 std::fs::write(&untracked, "after!\n").unwrap();
9364
9365 let error = repo
9366 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9367 .unwrap_err();
9368
9369 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9370 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9371 }
9372
9373 #[test]
9374 fn an_assume_unchanged_edit_is_detected() {
9375 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9376 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9377 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9378
9379 let error = repo
9380 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9381 .unwrap_err();
9382
9383 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9384 assert_eq!(
9385 "hidden\n",
9386 std::fs::read_to_string(path.join("README.md")).unwrap()
9387 );
9388 }
9389
9390 #[test]
9391 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9392 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9393 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9394 test_git(&path, &["add", "README.md"]);
9395 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9396
9397 let error = repo
9398 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9399 .unwrap_err();
9400
9401 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9402 assert_eq!(
9403 b"seed\r\n",
9404 std::fs::read(path.join("README.md")).unwrap().as_slice()
9405 );
9406 }
9407
9408 #[cfg(unix)]
9409 #[test]
9410 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9411 use std::os::unix::fs::PermissionsExt;
9412
9413 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9414 test_git(&path, &["config", "core.filemode", "false"]);
9415 let readme = path.join("README.md");
9416 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9417 permissions.set_mode(0o755);
9418 std::fs::set_permissions(&readme, permissions).unwrap();
9419 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9420
9421 let error = repo
9422 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9423 .unwrap_err();
9424
9425 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9426 assert_eq!(
9427 0o755,
9428 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9429 );
9430 }
9431
9432 #[test]
9433 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9434 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9435 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9436 test_git(&path, &["add", ".gitattributes"]);
9437 test_git(&path, &["commit", "-m", "select data filter"]);
9438 let baseline = repo.worktree_baseline(&path).unwrap();
9439 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9440
9441 assert!(repo
9442 .commit_pending_changes(&path, &baseline, "change data", "change data")
9443 .unwrap());
9444 let error = repo
9445 .refuse_unrepresented_tracked_changes(&path, &baseline)
9446 .unwrap_err();
9447
9448 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9449 assert_eq!(
9450 "secret: recover me\nnew\n",
9451 std::fs::read_to_string(path.join("data.txt")).unwrap()
9452 );
9453 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9454 }
9455
9456 #[test]
9457 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9458 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9459 std::fs::create_dir_all(path.join("target")).unwrap();
9460 let untracked = path.join("target/user.yaml");
9461 std::fs::write(&untracked, "user data\n").unwrap();
9462 let baseline = repo.worktree_baseline(&path).unwrap();
9463 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9464
9465 assert!(repo
9466 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9467 .unwrap());
9468
9469 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9470 assert_eq!(
9471 "?? target/user.yaml\n",
9472 test_git(&path, &["status", "--short", "--untracked-files=all"])
9473 );
9474 assert!(test_git(
9475 &path,
9476 &[
9477 "ls-tree",
9478 "-r",
9479 "--name-only",
9480 "HEAD",
9481 "--",
9482 "target/user.yaml"
9483 ]
9484 )
9485 .is_empty());
9486 }
9487
9488 #[test]
9489 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9490 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9491 std::fs::create_dir_all(path.join("target")).unwrap();
9492 let untracked = path.join("target/user.yaml");
9493 std::fs::write(&untracked, "before\n").unwrap();
9494 let baseline = repo.worktree_baseline(&path).unwrap();
9495 let before = test_git(&path, &["rev-parse", "HEAD"]);
9496 std::fs::write(&untracked, "after\n").unwrap();
9497 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9498
9499 let error = repo
9500 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9501 .unwrap_err();
9502
9503 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9504 assert!(error.to_string().contains("target/user.yaml"), "{error}");
9505 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9506 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9507 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9508 }
9509
9510 #[test]
9511 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9512 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9513 let baseline = repo.worktree_baseline(&path).unwrap();
9514 std::fs::create_dir_all(path.join("target")).unwrap();
9515 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9516
9517 assert!(repo
9518 .commit_pending_changes(&path, &baseline, "add file", "add file")
9519 .unwrap());
9520
9521 assert_eq!(
9522 "new file\n",
9523 test_git(&path, &["show", "HEAD:target/new.txt"])
9524 );
9525 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9526 }
9527
9528 #[test]
9529 fn a_managed_commit_accepts_a_custom_output_rebuild() {
9530 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9531 exclude_paths(&repo, &["manager/public/"]);
9532 let output = path.join("manager/public/assets");
9533 std::fs::create_dir_all(&output).unwrap();
9534 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9535 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9536 let baseline = repo.worktree_baseline(&path).unwrap();
9537 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9538 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9539 std::fs::remove_file(output.join("old.js")).unwrap();
9540 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9541 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9542
9543 assert!(repo
9544 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9545 .unwrap());
9546 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9547 repo.refuse_changed_existing_untracked(&path, &baseline)
9548 .unwrap();
9549 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9550 .unwrap();
9551
9552 assert_eq!(
9553 "tracked change\n",
9554 test_git(&path, &["show", "HEAD:README.md"])
9555 );
9556 assert_eq!(
9557 "new source\n",
9558 test_git(&path, &["show", "HEAD:new-source.txt"])
9559 );
9560 assert!(test_git(
9561 &path,
9562 &[
9563 "ls-tree",
9564 "-r",
9565 "--name-only",
9566 "HEAD",
9567 "--",
9568 "manager/public"
9569 ]
9570 )
9571 .is_empty());
9572 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9573 assert!(!output.join("old.js").exists());
9574 assert_eq!(
9575 "new styles\n",
9576 std::fs::read_to_string(output.join("index.css")).unwrap()
9577 );
9578 assert!(repository_has_recoverable_work(&path, true).unwrap());
9579 repo.release_review_worktree(912);
9580 assert!(output.join("index-DUym-Rfj.js").exists());
9581 }
9582
9583 #[test]
9584 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9585 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9586 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9587 let baseline = repo.worktree_baseline(&path).unwrap();
9588 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9589 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9590
9591 let error = repo
9592 .commit_pending_changes(&path, &baseline, "change", "change")
9593 .unwrap_err();
9594
9595 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9596 assert!(error.to_string().contains("notes.txt"), "{error}");
9597 }
9598
9599 #[test]
9600 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9601 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9602 let baseline = repo.worktree_baseline(&path).unwrap();
9603 let nested = path.join("generated/repro");
9604 std::fs::create_dir_all(&nested).unwrap();
9605 test_git(&nested, &["init"]);
9606 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9607
9608 assert!(repo
9609 .commit_pending_changes(&path, &baseline, "change", "change")
9610 .unwrap());
9611 assert!(!repo.has_uncommitted_changes(&path).unwrap());
9612 assert!(repository_has_recoverable_work(&path, true).unwrap());
9613 repo.release_review_worktree(927);
9614 assert!(nested.join(".git").exists());
9615 }
9616
9617 #[test]
9618 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9619 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9620 test_git(&path, &["config", "core.autocrlf", "true"]);
9621 std::fs::write(path.join("README.md"), "seed\n").unwrap();
9622 assert_eq!(
9623 test_git(&path, &["hash-object", "README.md"]).trim(),
9624 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9625 );
9626
9627 assert!(repository_has_recoverable_work(&path, true).unwrap());
9628 assert_eq!(
9629 "seed\n",
9630 std::fs::read_to_string(path.join("README.md")).unwrap()
9631 );
9632 }
9633
9634 #[test]
9635 fn autocrlf_input_overrides_a_crlf_core_eol() {
9636 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9637 test_git(&path, &["config", "core.autocrlf", "input"]);
9638 test_git(&path, &["config", "core.eol", "crlf"]);
9639 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9640 assert_eq!(
9641 test_git(&path, &["hash-object", "README.md"]).trim(),
9642 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9643 );
9644
9645 assert!(repository_has_recoverable_work(&path, true).unwrap());
9646 assert_eq!(
9647 b"seed\r\n",
9648 std::fs::read(path.join("README.md")).unwrap().as_slice()
9649 );
9650 }
9651
9652 #[cfg(unix)]
9653 #[test]
9654 fn a_non_executable_permission_change_is_recoverable() {
9655 use std::os::unix::fs::PermissionsExt;
9656
9657 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9658 let readme = path.join("README.md");
9659 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9660 permissions.set_mode(0o600);
9661 std::fs::set_permissions(&readme, permissions).unwrap();
9662 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9663
9664 let error = repo
9665 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9666 .unwrap_err();
9667
9668 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9669 assert!(repository_has_recoverable_work(&path, true).unwrap());
9670 assert_eq!(
9671 0o600,
9672 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9673 );
9674 }
9675
9676 #[cfg(unix)]
9677 #[test]
9678 fn a_managed_commit_skips_signing_and_hooks() {
9679 use std::os::unix::fs::PermissionsExt;
9680
9681 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9682 let common = common_git_dir(&path).unwrap();
9683 let hook = common.join("hooks/pre-commit");
9684 let marker = fixture.root.join("hook-ran");
9685 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9686 std::fs::write(
9687 &hook,
9688 format!(
9689 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9690 sh_quote(marker.to_str().unwrap())
9691 ),
9692 )
9693 .unwrap();
9694 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9695 permissions.set_mode(0o755);
9696 std::fs::set_permissions(&hook, permissions).unwrap();
9697 test_git(&path, &["config", "commit.gpgsign", "true"]);
9698 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9699 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9700 test_git(&path, &["add", "managed.txt"]);
9701
9702 repo.commit_staged_changes(&path, "record managed change")
9703 .unwrap();
9704
9705 assert!(!marker.exists());
9706 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9707 }
9708
9709 #[test]
9710 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9711 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9712 std::fs::write(
9713 path.join(".gitattributes"),
9714 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
9715 )
9716 .unwrap();
9717 test_git(&path, &["add", ".gitattributes"]);
9718 test_git(&path, &["commit", "-m", "select automatic text"]);
9719 test_git(&path, &["config", "core.autocrlf", "true"]);
9720
9721 assert!(repository_has_recoverable_work(&path, true).unwrap());
9722 }
9723
9724 #[test]
9725 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
9726 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
9727 std::fs::write(
9728 path.join(".gitattributes"),
9729 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
9730 )
9731 .unwrap();
9732 test_git(&path, &["add", ".gitattributes"]);
9733 test_git(&path, &["commit", "-m", "select ident expansion"]);
9734 std::fs::write(path.join("README.md"), "seed\n").unwrap();
9735
9736 assert!(repository_has_recoverable_work(&path, true).unwrap());
9737 }
9738
9739 #[test]
9745 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
9746 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
9747 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
9748 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9749 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
9750 std::fs::write(
9751 path.join("bindings/uniffi-bindgen/Cargo.lock"),
9752 "# generated\n",
9753 )
9754 .unwrap();
9755
9756 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9757 .expect("a path the project ignores is the call's own output");
9758 }
9759
9760 #[test]
9761 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
9762 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
9763 exclude_paths(&repo, &["target/"]);
9764 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9765 std::fs::create_dir_all(path.join("target/package")).unwrap();
9766 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
9767
9768 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9769 .expect("an ignored attribute file governs only ignored paths");
9770 assert!(
9771 !repo.has_uncommitted_changes(&path).unwrap(),
9772 "ignored dependency attributes must not stop the next review"
9773 );
9774 }
9775
9776 #[test]
9777 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
9778 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
9779 std::fs::create_dir_all(path.join("source")).unwrap();
9780 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
9781
9782 assert!(repo.has_uncommitted_changes(&path).unwrap());
9783 }
9784
9785 #[test]
9786 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
9787 for source in ["repository", "nested", "local", "global"] {
9788 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
9789 std::fs::create_dir_all(path.join("manager")).unwrap();
9790 match source {
9791 "repository" => {
9792 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
9793 .unwrap();
9794 test_git(&path, &["add", ".gitignore"]);
9795 test_git(&path, &["commit", "-m", "ignore artifacts"]);
9796 }
9797 "nested" => {
9798 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
9799 test_git(&path, &["add", "manager/.gitignore"]);
9800 test_git(&path, &["commit", "-m", "ignore artifacts"]);
9801 }
9802 "local" => exclude_paths(&repo, &["manager/scratch/"]),
9803 "global" => {
9804 let excludes = repo.root().parent().unwrap().join("global-ignore");
9805 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
9806 test_git(
9807 &path,
9808 &["config", "core.excludesFile", excludes.to_str().unwrap()],
9809 );
9810 }
9811 _ => unreachable!(),
9812 }
9813 let output = path.join("manager/scratch");
9814 std::fs::create_dir_all(&output).unwrap();
9815 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
9816 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
9817 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9818 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
9819 std::fs::remove_file(output.join(".DS_Store")).unwrap();
9820 for file in [
9821 ".gitattributes",
9822 "Cargo.lock",
9823 "font.woff2",
9824 "bundle.js",
9825 "cache.db",
9826 ] {
9827 std::fs::write(output.join(file), "generated\n").unwrap();
9828 }
9829 #[cfg(unix)]
9830 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
9831 let nested = output.join("dependency");
9832 std::fs::create_dir_all(&nested).unwrap();
9833 test_git(&nested, &["init"]);
9834 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9835 .unwrap_or_else(|error| panic!("{source}: {error}"));
9836 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9837
9838 let baseline = repo.worktree_baseline(&path).unwrap();
9839 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
9840 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
9841 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
9842 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
9843 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
9844 assert!(repo
9845 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
9846 .unwrap_or_else(|error| panic!("{source}: {error}")));
9847 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9848 assert!(test_git(
9849 &path,
9850 &[
9851 "ls-tree",
9852 "-r",
9853 "--name-only",
9854 "HEAD",
9855 "--",
9856 "manager/scratch"
9857 ]
9858 )
9859 .is_empty());
9860 assert!(
9861 repository_has_recoverable_work(&path, true).unwrap(),
9862 "{source}"
9863 );
9864 repo.release_review_worktree(945);
9865 assert_eq!(
9866 "LOCAL=updated\n",
9867 std::fs::read_to_string(output.join(".env.local")).unwrap()
9868 );
9869 assert!(nested.join(".git").exists(), "{source}");
9870 }
9871 }
9872
9873 #[test]
9877 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
9878 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
9879 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9880 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
9881
9882 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9883 .expect("spar's own marker is not something a call did");
9884 }
9885
9886 #[test]
9887 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
9888 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
9889
9890 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9891 .expect("a path a build removed is not an unreadable worktree");
9892
9893 assert_eq!(vanished_untracked_file(), gone);
9894 }
9895
9896 #[test]
9899 fn a_vanished_path_is_not_recorded_as_absent() {
9900 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9901 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9902 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9903 std::fs::remove_file(path.join("notes.txt")).unwrap();
9904
9905 let error = repo
9906 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9907 .unwrap_err();
9908
9909 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9910 }
9911
9912 fn exclude_paths(repo: &Repo, lines: &[&str]) {
9913 use std::io::Write;
9914 let path = repo.root().join(".git").join("info").join("exclude");
9915 let mut file = std::fs::OpenOptions::new()
9916 .create(true)
9917 .append(true)
9918 .open(&path)
9919 .unwrap();
9920 for line in lines {
9921 writeln!(file, "{line}").unwrap();
9922 }
9923 }
9924
9925 #[test]
9926 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9927 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9928 exclude_paths(&repo, &["dist/"]);
9929 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9930 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9931 let baseline = repo.worktree_baseline(&path).unwrap();
9932 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9933 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9934 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9935 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9936
9937 assert!(repo
9938 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9939 .unwrap());
9940 assert_eq!(
9941 "tracked change\n",
9942 std::fs::read_to_string(path.join("README.md")).unwrap()
9943 );
9944 }
9945
9946 #[test]
9947 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
9948 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
9949 exclude_paths(&repo, &["dist/"]);
9950 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9951 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9952 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
9953 let baseline = repo.worktree_baseline(&path).unwrap();
9954 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9955 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
9956 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9957 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9958
9959 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9960 repo.refuse_changed_existing_untracked(&path, &baseline)
9961 .unwrap();
9962 }
9963
9964 #[test]
9965 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
9966 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
9967 exclude_paths(&repo, &["dist/"]);
9968 std::fs::create_dir_all(path.join("dist")).unwrap();
9969 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9970 let baseline = repo.worktree_baseline(&path).unwrap();
9971 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9972 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9973
9974 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9975 repo.refuse_changed_existing_untracked(&path, &baseline)
9976 .unwrap();
9977 }
9978
9979 #[test]
9988 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
9989 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
9990 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
9991 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
9992 test_git(
9993 repo.root(),
9994 &[
9995 "worktree",
9996 "add",
9997 "-b",
9998 "issue-950",
9999 held.to_str().unwrap(),
10000 "main",
10001 ],
10002 );
10003
10004 let found = repo
10005 .worktree_holding("issue-950")
10006 .expect("the branch is checked out somewhere");
10007
10008 assert!(found.ends_with("issue-950"), "{}", found.display());
10009 assert_eq!(None, repo.worktree_holding("issue-951"));
10010 }
10011
10012 #[test]
10013 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10014 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10015 exclude_paths(&repo, &["manager/public/assets/"]);
10016 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10017 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10018 std::fs::write(&bundle, "first build\n").unwrap();
10019 let baseline = repo.worktree_baseline(&path).unwrap();
10020 std::fs::write(&bundle, "second build\n").unwrap();
10021 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10022
10023 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10024 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10025 }
10026
10027 #[test]
10028 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10029 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10030 exclude_paths(&repo, &["dist/"]);
10031 let baseline = repo.worktree_baseline(&path).unwrap();
10032 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10033
10034 let error = repo
10035 .refuse_read_only_leavings(&path, &baseline)
10036 .unwrap_err();
10037
10038 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10039 assert!(error.to_string().contains("scratch.md"), "{error}");
10040 }
10041
10042 #[test]
10043 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10044 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10045 exclude_paths(&repo, &["dist/", ".env.local"]);
10046 std::fs::create_dir_all(path.join("dist")).unwrap();
10047 let baseline = repo.worktree_baseline(&path).unwrap();
10048 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10049 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10050
10051 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10052 assert!(repository_has_recoverable_work(&path, true).unwrap());
10053 }
10054
10055 #[test]
10056 fn a_read_only_inspection_may_rebuild_generated_output() {
10057 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10058 exclude_paths(&repo, &["dist/"]);
10059 std::fs::create_dir_all(path.join("dist")).unwrap();
10060 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10061 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10062 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10063 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10064
10065 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10066 .unwrap();
10067 }
10068
10069 #[test]
10070 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10071 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10072 exclude_paths(&repo, &["dist/", ".env.local"]);
10073 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10074 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10075 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10076
10077 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10078 .unwrap();
10079 }
10080
10081 #[test]
10082 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10083 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10084 exclude_paths(&repo, &["dist/", ".env.local"]);
10085 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10086 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10087 std::fs::remove_file(path.join(".env.local")).unwrap();
10088
10089 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10090 .unwrap();
10091 }
10092
10093 #[test]
10094 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10095 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10096 exclude_paths(&repo, &["dist/"]);
10097 std::fs::write(path.join("notes.md"), "before\n").unwrap();
10098 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10099 std::fs::write(path.join("notes.md"), "after\n").unwrap();
10100
10101 let error = repo
10102 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10103 .unwrap_err();
10104
10105 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10106 assert!(error.to_string().contains("notes.md"), "{error}");
10107 }
10108
10109 #[test]
10110 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10111 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10112 exclude_paths(&repo, &["dist/", "local.env"]);
10113 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10114 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10115 std::fs::write(
10118 repo.root().join(".git").join("info").join("exclude"),
10119 "dist/\n",
10120 )
10121 .unwrap();
10122
10123 let error = repo
10124 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10125 .unwrap_err();
10126
10127 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10128 assert!(error.to_string().contains("local.env"), "{error}");
10129 }
10130
10131 #[test]
10132 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10133 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10134 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10135 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10136
10137 let error = repo
10138 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10139 .unwrap_err();
10140
10141 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10142 assert!(error.to_string().contains("README.md"), "{error}");
10143 }
10144
10145 #[test]
10146 fn build_output_alone_does_not_keep_a_worktree() {
10147 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10148 exclude_paths(&repo, &["target/", "dist/"]);
10149 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10150 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10151 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10152 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10153
10154 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10155 repo.release_review_worktree(933);
10156
10157 assert!(!path.exists());
10158 }
10159
10160 #[test]
10161 fn finder_metadata_alone_does_not_keep_a_worktree() {
10162 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10163 exclude_paths(&repo, &[".DS_Store"]);
10164 std::fs::create_dir_all(path.join("src")).unwrap();
10165 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10166 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10167
10168 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10169 repo.release_review_worktree(937);
10170
10171 assert!(!path.exists());
10172 }
10173
10174 #[test]
10175 fn a_fetch_record_alone_does_not_keep_a_worktree() {
10176 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10177 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10178 let git_dir = if raw.is_absolute() {
10179 raw
10180 } else {
10181 path.join(raw)
10182 };
10183 let head = test_git(&path, &["rev-parse", "HEAD"]);
10184 std::fs::write(
10185 git_dir.join("FETCH_HEAD"),
10186 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10187 )
10188 .unwrap();
10189
10190 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10191 repo.release_review_worktree(938);
10192
10193 assert!(!path.exists());
10194 }
10195
10196 #[test]
10197 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10198 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10199 exclude_paths(&repo, &["target/", ".env.local"]);
10200 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10201 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10202 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10203
10204 assert!(repository_has_recoverable_work(&path, true).unwrap());
10205 repo.release_review_worktree(934);
10206
10207 assert_eq!(
10208 "TOKEN=keep me\n",
10209 std::fs::read_to_string(path.join(".env.local")).unwrap()
10210 );
10211 }
10212
10213 #[test]
10214 fn a_repository_nested_in_build_output_keeps_a_worktree() {
10215 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10216 exclude_paths(&repo, &["node_modules/"]);
10217 let nested = path.join("node_modules/local-dep");
10218 std::fs::create_dir_all(&nested).unwrap();
10219 test_git(&nested, &["init"]);
10220 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10221
10222 assert!(repository_has_recoverable_work(&path, true).unwrap());
10223 repo.release_review_worktree(935);
10224
10225 assert!(nested.join(".git").exists());
10226 }
10227
10228 #[test]
10229 fn an_ordinary_untracked_file_keeps_a_worktree() {
10230 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10231 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10232
10233 assert!(repository_has_recoverable_work(&path, true).unwrap());
10234 repo.release_review_worktree(936);
10235
10236 assert_eq!(
10237 "somebody's notes\n",
10238 std::fs::read_to_string(path.join("notes.md")).unwrap()
10239 );
10240 }
10241
10242 #[test]
10243 fn a_legacy_crlf_checkout_is_retained_conservatively() {
10244 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10245 std::fs::write(
10246 path.join(".gitattributes"),
10247 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10248 )
10249 .unwrap();
10250 test_git(&path, &["add", ".gitattributes"]);
10251 test_git(&path, &["commit", "-m", "select legacy line endings"]);
10252
10253 assert!(repository_has_recoverable_work(&path, true).unwrap());
10254 }
10255
10256 #[test]
10257 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10258 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10259 let nested = path.join("tracked");
10260 std::fs::create_dir_all(&nested).unwrap();
10261 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10262 test_git(&path, &["add", "tracked/seed.txt"]);
10263 test_git(&path, &["commit", "-m", "add tracked directory"]);
10264 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10265 test_git(&nested, &["init"]);
10266
10267 let error = repo
10268 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10269 .unwrap_err();
10270
10271 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10272 assert!(error.to_string().contains("Git entry"), "{error}");
10273 assert!(nested.join(".git").exists());
10274 }
10275
10276 #[test]
10281 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10282 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10283 exclude_paths(&repo, &["target/"]);
10284 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10285 let repro = path.join("target/pr918-repro");
10286 std::fs::create_dir_all(&repro).unwrap();
10287 test_git(&repro, &["init"]);
10288 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10289
10290 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10291 .unwrap();
10292
10293 assert!(repository_has_recoverable_work(&path, true).unwrap());
10294 assert!(repro.join(".git").exists());
10295 }
10296
10297 #[test]
10301 fn a_repository_inside_a_nested_checkout_is_left_alone() {
10302 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10303 exclude_paths(&repo, &["target/"]);
10304 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10305 let repro = path.join("target/repro");
10306 let vendored = repro.join("vendor/dep");
10307 std::fs::create_dir_all(&vendored).unwrap();
10308 test_git(&repro, &["init"]);
10309 test_git(&vendored, &["init"]);
10310
10311 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10312 .unwrap();
10313
10314 assert!(vendored.join(".git").exists());
10315 }
10316
10317 #[test]
10318 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10319 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10320
10321 let state = ignored_untracked_state(repo.root()).unwrap();
10322
10323 let relative = path.strip_prefix(repo.root()).unwrap();
10324 assert!(
10325 state.files.contains_key(relative),
10326 "{:?}",
10327 state.files.keys().collect::<Vec<_>>()
10328 );
10329 assert!(state.is_ignored(relative));
10330 }
10331
10332 #[test]
10333 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10334 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10335 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10336 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10337 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10338
10339 repo.refuse_new_ignored_files(repo.root(), &baseline)
10340 .unwrap();
10341 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10342 .unwrap();
10343 }
10344
10345 #[test]
10346 fn deleting_a_resident_worktree_during_a_call_is_refused() {
10347 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10348 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10349 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10350 std::fs::remove_dir_all(&path).unwrap();
10351
10352 let read_error = repo
10353 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10354 .unwrap_err();
10355 assert!(
10356 read_error.to_string().contains("review-932"),
10357 "{read_error}"
10358 );
10359 let error = repo
10360 .refuse_new_ignored_files(repo.root(), &baseline)
10361 .unwrap_err();
10362
10363 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10364 assert!(error.to_string().contains("review-932"), "{error}");
10365 }
10366
10367 #[test]
10368 fn a_nested_repository_record_is_read_as_a_plain_path() {
10369 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10370 assert_eq!(Path::new("vendor/checkout"), path);
10371 assert!(nested);
10372
10373 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10374 assert_eq!(Path::new("vendor/notes.txt"), path);
10375 assert!(!nested);
10376
10377 assert!(untracked_record(b"/", "untracked").is_err());
10378 }
10379
10380 #[cfg(unix)]
10381 #[test]
10382 fn a_non_utf8_git_path_is_preserved_without_loss() {
10383 use std::os::unix::ffi::OsStrExt;
10384
10385 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10386
10387 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10388 }
10389
10390 #[test]
10391 fn guarded_merge_pins_the_reviewed_head() {
10392 let args = merge_pr_args("36", Some("abc123"), true);
10393 assert_eq!(
10394 vec![
10395 "pr",
10396 "merge",
10397 "36",
10398 "--squash",
10399 "--delete-branch",
10400 "--match-head-commit",
10401 "abc123"
10402 ],
10403 args
10404 );
10405 }
10406
10407 #[test]
10408 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10409 let pr = PrRef {
10410 number: 7,
10411 url: "https://example.test/pull/7".into(),
10412 title: "part one".into(),
10413 };
10414 let result = reconcile_pr_creation(
10415 "split-34-1",
10416 Err(crate::error::SparError::new("connection lost")),
10417 Ok(Some(pr)),
10418 )
10419 .unwrap();
10420 assert_eq!(7, result.number);
10421 }
10422
10423 #[test]
10424 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10425 let error = reconcile_pr_creation(
10426 "split-34-1",
10427 Err(crate::error::SparError::new("permission denied")),
10428 Ok(None),
10429 )
10430 .unwrap_err();
10431 assert!(error.to_string().contains("permission denied"), "{error}");
10432 }
10433
10434 #[test]
10435 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10436 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10437 assert!(pr_for_base(text, "split-34-2", "split-34-1")
10438 .unwrap()
10439 .is_none());
10440 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10441 assert_eq!(7, found.number);
10442 }
10443
10444 #[test]
10445 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10446 let result = reconcile_comment_post(
10447 34,
10448 "the summary",
10449 crate::error::SparError::new("connection lost"),
10450 Ok(vec![serde_json::json!({"body": "the summary"})]),
10451 );
10452 assert!(result.is_ok(), "{result:?}");
10453 }
10454
10455 #[test]
10456 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10457 let error = reconcile_comment_post(
10458 34,
10459 "the summary",
10460 crate::error::SparError::new("connection lost"),
10461 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10462 )
10463 .unwrap_err();
10464 assert_eq!("connection lost", error.to_string());
10465 }
10466
10467 #[test]
10468 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10469 let error = reconcile_comment_post(
10470 34,
10471 "the summary",
10472 crate::error::SparError::new("connection lost"),
10473 Err(crate::error::SparError::new("comments unavailable")),
10474 )
10475 .unwrap_err();
10476 assert!(
10477 error.to_string().contains("could not be verified"),
10478 "{error}"
10479 );
10480 assert!(
10481 error.to_string().contains("comments unavailable"),
10482 "{error}"
10483 );
10484 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10485 assert!(!error.worth_retrying());
10486 }
10487
10488 #[test]
10489 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10490 let result = reconcile_issue_edit(
10491 34,
10492 "wanted body",
10493 crate::error::SparError::new("connection lost"),
10494 Ok("wanted body".to_string()),
10495 );
10496 assert!(result.is_ok(), "{result:?}");
10497 }
10498
10499 #[test]
10500 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10501 let error = reconcile_issue_edit(
10502 34,
10503 "wanted body",
10504 crate::error::SparError::new("connection lost"),
10505 Err(crate::error::SparError::new("issue unavailable")),
10506 )
10507 .unwrap_err();
10508 assert!(
10509 error.to_string().contains("could not be verified"),
10510 "{error}"
10511 );
10512 assert!(error.to_string().contains("issue unavailable"), "{error}");
10513 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10514 assert!(!error.worth_retrying());
10515 }
10516
10517 #[test]
10518 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10519 let found = ExistingIssue {
10520 number: 101,
10521 url: "https://example.test/issues/101".into(),
10522 title: "child".into(),
10523 body: "body".into(),
10524 open: true,
10525 };
10526 let url = reconcile_issue_creation(
10527 "child",
10528 Err(crate::error::SparError::new("connection lost")),
10529 Ok(Some(found)),
10530 )
10531 .unwrap();
10532 assert_eq!("https://example.test/issues/101", url);
10533 }
10534
10535 #[test]
10536 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10537 let error = reconcile_issue_creation(
10538 "child",
10539 Err(crate::error::SparError::new("permission denied")),
10540 Ok(None),
10541 )
10542 .unwrap_err();
10543 assert!(error.to_string().contains("permission denied"), "{error}");
10544 }
10545
10546 #[test]
10547 fn an_unverifiable_issue_creation_is_marked_uncertain() {
10548 let error = reconcile_issue_creation(
10549 "child",
10550 Err(crate::error::SparError::new("connection lost")),
10551 Err(crate::error::SparError::new("issues unavailable")),
10552 )
10553 .unwrap_err();
10554 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10555 assert!(!error.worth_retrying());
10556 }
10557
10558 #[test]
10559 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10560 let result = reconcile_failed_split_push(
10561 "split-34-1",
10562 crate::error::SparError::new("connection lost"),
10563 Ok("abc123\n".into()),
10564 Ok("abc123\trefs/heads/split-34-1\n".into()),
10565 );
10566 assert!(result.is_ok(), "{result:?}");
10567 }
10568
10569 #[test]
10570 fn a_split_push_collision_is_definite_and_never_overwrites() {
10571 let error = reconcile_failed_split_push(
10572 "split-34-1",
10573 crate::error::SparError::new("lease rejected"),
10574 Ok("abc123\n".into()),
10575 Ok("def456\trefs/heads/split-34-1\n".into()),
10576 )
10577 .unwrap_err();
10578 assert!(!error.retain_worktree());
10579 assert!(
10580 error.to_string().contains("Nothing was overwritten"),
10581 "{error}"
10582 );
10583 }
10584
10585 #[test]
10586 fn an_unreadable_split_push_result_keeps_the_worktree() {
10587 let error = reconcile_failed_split_push(
10588 "split-34-1",
10589 crate::error::SparError::new("connection lost"),
10590 Ok("abc123\n".into()),
10591 Err(crate::error::SparError::new("origin unavailable")),
10592 )
10593 .unwrap_err();
10594 assert!(error.retain_worktree());
10595 assert!(error.to_string().contains("could not confirm"), "{error}");
10596 }
10597
10598 #[test]
10602 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10603 let repo = repo_for_titles();
10604 for raw in [
10605 "Retry loop spins \u{2014} Retry-After parses to zero",
10606 "plain title",
10607 " spread over\nlines ",
10608 "\u{1F916} Generated with something",
10609 &format!("a \u{2014} {}", "very long title ".repeat(20)),
10610 &"x".repeat(300),
10611 &format!("{} \u{2014} end", "y".repeat(88)),
10612 &{
10617 let tail = "a\u{2014}b c\u{2014}d";
10618 let pad = Style::default().max_title_chars - tail.chars().count();
10619 format!("{}{tail}", "w".repeat(pad))
10620 },
10621 ] {
10622 let once = repo.clean_title(raw).unwrap();
10623 let twice = repo.clean_title(&once).unwrap();
10624 assert_eq!(once, twice, "not idempotent for {raw:?}");
10625 assert!(
10626 once.chars().count() <= repo.style.max_title_chars,
10627 "over budget: {once:?}"
10628 );
10629 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10630 }
10631 }
10632
10633 #[test]
10634 fn a_title_with_an_em_dash_survives_as_readable_text() {
10635 let repo = repo_for_titles();
10636 assert_eq!(
10637 "Retry loop spins, Retry-After parses to zero",
10638 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10639 .unwrap()
10640 );
10641 }
10642
10643 #[test]
10644 fn sh_quote_survives_a_quote() {
10645 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10646 }
10647
10648 #[test]
10649 fn sh_quote_wraps_a_space() {
10650 assert_eq!(
10651 "'/Applications/My App/spar'",
10652 sh_quote("/Applications/My App/spar")
10653 );
10654 }
10655
10656 #[test]
10657 fn finished_states_are_recognised_case_insensitively() {
10658 assert!(is_finished("MERGED"));
10659 assert!(is_finished("closed"));
10660 assert!(!is_finished("OPEN"));
10661 assert!(!is_finished(""));
10662 }
10663
10664 fn state() -> PersistedState {
10665 PersistedState {
10666 version: 1,
10667 checkpoint: 0,
10668 round: 4,
10669 next_actor: "codex".into(),
10670 status: Status::Pending,
10671 pr_head: "abc123".into(),
10672 ledger: Ledger::new(),
10673 filed: vec![],
10674 open_findings: vec![Finding {
10675 severity: Severity::Blocking,
10676 title: "Unchecked error".into(),
10677 detail: "the failure is discarded".into(),
10678 file: "src/a.rs:12".into(),
10679 ..Finding::default()
10680 }],
10681 disputes: vec![Dispute {
10682 title: "Retry limit".into(),
10683 file: "src/net.rs".into(),
10684 reasoning: "the caller already bounds it".into(),
10685 }],
10686 noted: vec![Finding {
10687 severity: Severity::NonBlocking,
10688 title: "Timeout is fixed".into(),
10689 file: "src/config.rs".into(),
10690 ..Finding::default()
10691 }],
10692 }
10693 }
10694
10695 #[test]
10696 fn a_state_comment_round_trips() {
10697 let body = format!(
10698 "{STATE_MARKER}\n{}\n-->",
10699 serde_json::to_string(&state()).unwrap()
10700 );
10701 let back = parse_state_comment(&body).unwrap();
10702 assert_eq!(4, back.round);
10703 assert_eq!("codex", back.next_actor);
10704 assert_eq!("abc123", back.pr_head);
10705 assert_eq!("Unchecked error", back.open_findings[0].title);
10706 assert_eq!("src/net.rs", back.disputes[0].file);
10707 assert_eq!("Timeout is fixed", back.noted[0].title);
10708 }
10709
10710 #[test]
10711 fn old_state_without_new_lists_still_parses() {
10712 let body = format!(
10713 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
10714 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10715 );
10716 let back = parse_state_comment(&body).expect("old state");
10717 assert!(back.open_findings.is_empty());
10718 assert!(back.disputes.is_empty());
10719 assert!(back.noted.is_empty());
10720 assert!(back.pr_head.is_empty());
10721 assert_eq!(0, back.checkpoint);
10722 }
10723
10724 #[test]
10725 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
10726 let mut local = state();
10727 local.pr_head = "old".into();
10728 local.round = 9;
10729 let mut remote = state();
10730 remote.pr_head = "current".into();
10731 remote.round = 4;
10732
10733 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10734 assert_eq!("current", chosen.pr_head);
10735 assert_eq!(4, chosen.round);
10736 }
10737
10738 #[test]
10739 fn checkpoint_order_breaks_same_round_ties() {
10740 let mut local = state();
10741 local.pr_head = "current".into();
10742 local.round = 4;
10743 local.checkpoint = 8;
10744 let mut remote = local.clone();
10745 remote.checkpoint = 7;
10746 remote.open_findings.clear();
10747
10748 let chosen = choose_state_for_head(vec![local], "current").unwrap();
10749 assert_eq!(8, chosen.checkpoint);
10750
10751 let mut local = state();
10752 local.pr_head = "current".into();
10753 local.round = 4;
10754 local.checkpoint = 8;
10755 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
10756 assert_eq!(8, chosen.checkpoint);
10757 }
10758
10759 #[test]
10760 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
10761 let mut local = state();
10762 local.pr_head = "current".into();
10763 local.round = 4;
10764 local.open_findings.push(Finding {
10765 title: "local checkpoint".into(),
10766 ..Finding::default()
10767 });
10768 let mut remote = state();
10769 remote.pr_head = "current".into();
10770 remote.round = 4;
10771
10772 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10773 assert_eq!(
10774 "local checkpoint",
10775 chosen.open_findings.last().unwrap().title
10776 );
10777 }
10778
10779 #[test]
10781 fn the_state_block_is_an_html_comment() {
10782 let body = format!(
10783 "{STATE_MARKER}\n{}\n-->",
10784 serde_json::to_string(&state()).unwrap()
10785 );
10786 assert!(body.starts_with("<!--"));
10787 assert!(body.trim_end().ends_with("-->"));
10788 assert!(!body[..body.find('{').unwrap()].contains("-->"));
10789 }
10790
10791 #[test]
10792 fn an_unrelated_json_block_is_not_state() {
10793 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
10794 }
10795
10796 #[test]
10803 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
10804 let block = format!(
10805 "{STATE_MARKER}\n{}\n-->",
10806 serde_json::to_string(&state()).unwrap()
10807 );
10808 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
10809 assert!(
10810 is_state_comment(&block, "Spar-Bot", "spar-bot"),
10811 "a login differs only in case"
10812 );
10813
10814 assert!(
10815 !is_state_comment(
10816 "why is there a `<!-- spar:state` block on this PR?",
10817 "maintainer",
10818 "spar-bot"
10819 ),
10820 "a question about the block was treated as the block"
10821 );
10822 assert!(
10823 !is_state_comment(&block, "maintainer", "spar-bot"),
10824 "somebody else's comment is not spar's to rewrite"
10825 );
10826 assert!(
10827 !is_state_comment(
10828 &format!("here is what spar writes:\n\n{block}"),
10829 "spar-bot",
10830 "spar-bot"
10831 ),
10832 "the marker has to open the body, not appear in it"
10833 );
10834 assert!(
10835 !is_state_comment(
10836 &format!("{STATE_MARKER}\n{{not json\n-->"),
10837 "spar-bot",
10838 "spar-bot"
10839 ),
10840 "a block that does not parse is not state"
10841 );
10842 assert!(
10843 !is_state_comment(&block, "spar-bot", ""),
10844 "an unknown viewer proves nothing about who wrote it"
10845 );
10846 }
10847
10848 #[test]
10849 fn a_malformed_state_comment_is_none_not_a_panic() {
10850 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
10851 }
10852
10853 #[test]
10854 fn atomic_write_leaves_no_temp_file() {
10855 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
10856 let _ = std::fs::remove_dir_all(&dir);
10857 let path = dir.join("state").join("pr-7.json");
10858 write_json_atomic(&path, &state()).unwrap();
10859 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
10860 .unwrap()
10861 .flatten()
10862 .filter_map(|e| e.file_name().to_str().map(str::to_string))
10863 .collect();
10864 assert_eq!(vec!["pr-7.json".to_string()], files);
10865 let _ = std::fs::remove_dir_all(&dir);
10866 }
10867
10868 #[test]
10869 fn atomic_write_overwrites_rather_than_accumulating() {
10870 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
10871 let _ = std::fs::remove_dir_all(&dir);
10872 let path = dir.join("pr-7.json");
10873 for round in 1..4 {
10874 let mut s = state();
10875 s.round = round;
10876 write_json_atomic(&path, &s).unwrap();
10877 }
10878 let back: PersistedState =
10879 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10880 assert_eq!(3, back.round);
10881 let _ = std::fs::remove_dir_all(&dir);
10882 }
10883
10884 #[test]
10885 fn style_from_env_defaults_to_enforcing() {
10886 std::env::remove_var("SPAR_BAN_EM_DASH");
10887 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
10888 let style = style_from_env();
10889 assert!(style.ban_em_dash && style.ban_ai_attribution);
10890 assert!(
10891 !style.terse,
10892 "the commit filter must not truncate a commit message"
10893 );
10894 }
10895
10896 #[test]
10901 fn a_push_denial_is_read_from_either_transport() {
10902 let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
10903 fatal: Could not read from remote repository.\n\n\
10904 Please make sure you have the correct access rights\n\
10905 and the repository exists.";
10906 let https = "remote: Permission to example/project.git denied to someone.\n\
10907 fatal: unable to access 'https://github.com/example/project.git/': The \
10908 requested URL returned error: 403";
10909 let denied = Some("Permission to example/project.git denied to someone.");
10910 assert_eq!(denied, push_denial(ssh));
10911 assert_eq!(denied, push_denial(https));
10912
10913 let stale = "To github.com:example/project.git\n \
10914 ! [rejected] HEAD -> issue-7 (stale info)\n\
10915 error: failed to push some refs to 'github.com:example/project.git'";
10916 assert_eq!(None, push_denial(stale));
10917
10918 let err = push_error(
10919 &PushTarget::Origin,
10920 "issue-7",
10921 &crate::error::SparError::new(ssh),
10922 );
10923 assert_eq!(
10924 "could not push to origin/issue-7. Permission to example/project.git denied to \
10925 someone.",
10926 err.to_string()
10927 );
10928 }
10929
10930 #[cfg(unix)]
10934 fn denied_fixture(
10935 tag: &str,
10936 fork_when_denied: bool,
10937 ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
10938 use std::os::unix::fs::PermissionsExt;
10939 use std::sync::atomic::{AtomicU32, Ordering};
10940 static NEXT: AtomicU32 = AtomicU32::new(0);
10941 let id = NEXT.fetch_add(1, Ordering::Relaxed);
10942 let root =
10943 std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
10944 let origin = root.join("origin.git");
10945 let fork = root.join("fork.git");
10946 let work = root.join("work");
10947 for dir in [&origin, &fork, &work] {
10948 std::fs::create_dir_all(dir).unwrap();
10949 }
10950 test_git(&origin, &["init", "--bare", "-b", "main"]);
10951 test_git(&fork, &["init", "--bare", "-b", "main"]);
10952 test_git(&work, &["init", "-b", "main"]);
10953 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
10954 test_git(&work, &["config", "user.name", "spar test"]);
10955 test_git(&work, &["config", "commit.gpgsign", "false"]);
10956 std::fs::write(work.join("README.md"), "seed\n").unwrap();
10957 test_git(&work, &["add", "."]);
10958 test_git(&work, &["commit", "-m", "seed"]);
10959 test_git(
10960 &work,
10961 &["remote", "add", "origin", origin.to_str().unwrap()],
10962 );
10963 test_git(&work, &["push", "-q", "origin", "main"]);
10964 let hook = origin.join("hooks").join("pre-receive");
10966 std::fs::write(
10967 &hook,
10968 "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
10969 )
10970 .unwrap();
10971 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10972 test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
10973 std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
10974 test_git(&work, &["add", "."]);
10975 test_git(&work, &["commit", "-m", "Fix it"]);
10976
10977 let mut text =
10978 String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
10979 if !fork_when_denied {
10980 text.push_str("[loop]\nfork_when_denied = false\n");
10981 }
10982 let cfg = crate::config::parse(&text).unwrap();
10983 let repo = Repo::open(&work, &cfg).unwrap();
10984 repo.fork
10986 .set(Fork {
10987 owner: "someone".into(),
10988 full_name: "someone/project".into(),
10989 url: fork.to_str().unwrap().into(),
10990 })
10991 .unwrap();
10992 (ReviewFixture { root }, repo, work, origin, fork)
10993 }
10994
10995 #[cfg(unix)]
10996 #[test]
10997 fn a_new_branch_origin_refuses_is_published_from_the_fork() {
10998 let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
10999 let first = test_git(&work, &["rev-parse", "HEAD"]);
11000
11001 let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11002
11003 assert_eq!("someone:issue-7", target.head("issue-7"));
11004 assert_eq!(
11005 first.trim(),
11006 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11007 );
11008 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11009 .trim()
11010 .is_empty());
11011 assert_eq!(
11012 WriteSummary {
11013 attempted: 1,
11014 failed: 0
11015 },
11016 repo.write_summary(),
11017 "a refusal the fork answered is not a failed write"
11018 );
11019
11020 std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11023 test_git(&work, &["commit", "-qam", "Fix it again"]);
11024 repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11025 .unwrap();
11026 let second = test_git(&work, &["rev-parse", "HEAD"]);
11027 assert_eq!(
11028 second.trim(),
11029 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11030 );
11031 }
11032
11033 #[cfg(unix)]
11037 #[test]
11038 fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11039 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11040 test_git(
11041 &work,
11042 &[
11043 "push",
11044 "-q",
11045 fork.to_str().unwrap(),
11046 "main:refs/heads/issue-7",
11047 ],
11048 );
11049 let head = test_git(&work, &["rev-parse", "HEAD"]);
11050
11051 repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11052
11053 assert_eq!(
11054 head.trim(),
11055 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11056 );
11057 assert_eq!(0, repo.write_summary().failed);
11058 }
11059
11060 #[cfg(unix)]
11061 #[test]
11062 fn work_only_the_fork_has_is_not_published_over() {
11063 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11064 test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11065 std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11066 test_git(&work, &["add", "."]);
11067 test_git(&work, &["commit", "-qm", "Somebody's work"]);
11068 test_git(
11069 &work,
11070 &[
11071 "push",
11072 "-q",
11073 fork.to_str().unwrap(),
11074 "elsewhere:refs/heads/issue-7",
11075 ],
11076 );
11077 let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11078 test_git(&work, &["checkout", "-q", "issue-7"]);
11079
11080 let err = repo
11081 .publish_new_branch(&work, "issue-7", "main")
11082 .unwrap_err()
11083 .to_string();
11084
11085 assert!(
11086 err.contains(
11087 "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11088 ),
11089 "{err}"
11090 );
11091 assert!(err.contains("--delete issue-7"), "{err}");
11092 assert_eq!(
11093 theirs.trim(),
11094 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11095 );
11096 assert_eq!(
11097 WriteSummary {
11098 attempted: 1,
11099 failed: 1
11100 },
11101 repo.write_summary()
11102 );
11103 }
11104
11105 #[test]
11107 fn a_rejected_push_names_the_ref_it_rejected() {
11108 let stale = "To github.com:example/project.git\n \
11109 ! [rejected] HEAD -> issue-7 (stale info)\n\
11110 error: failed to push some refs to 'github.com:example/project.git'";
11111
11112 let err = push_error(
11113 &PushTarget::Origin,
11114 "issue-7",
11115 &crate::error::SparError::new(stale),
11116 )
11117 .to_string();
11118
11119 assert!(
11120 err.starts_with(
11121 "could not push to origin/issue-7. [rejected] HEAD -> issue-7 (stale info)"
11122 ),
11123 "{err}"
11124 );
11125 }
11126
11127 #[test]
11128 fn a_fork_is_reached_by_whatever_reaches_origin() {
11129 let cases = [
11130 (
11131 "git@github.com:example/project.git",
11132 Some("git@github.com:someone/project.git"),
11133 ),
11134 (
11136 "github.com:example/project.git",
11137 Some("github.com:someone/project.git"),
11138 ),
11139 (
11141 "git@github-work:example/project.git",
11142 Some("git@github-work:someone/project.git"),
11143 ),
11144 (
11146 "ssh://git@ssh.github.com:443/example/project.git",
11147 Some("ssh://git@ssh.github.com:443/someone/project.git"),
11148 ),
11149 (
11150 "https://github.com/example/project",
11151 Some("https://github.com/someone/project"),
11152 ),
11153 (
11154 "https://ghe.example.com/scm/example/project.git/",
11155 Some("https://ghe.example.com/scm/someone/project.git"),
11156 ),
11157 (
11158 "git@host:/srv/example/project.git",
11159 Some("git@host:/srv/someone/project.git"),
11160 ),
11161 ("/srv/git/project.git", None),
11162 ("https://github.com/project", None),
11163 ("", None),
11164 ];
11165 for (origin, want) in cases {
11166 assert_eq!(
11167 want.map(str::to_string),
11168 fork_url(origin, "someone/project"),
11169 "{origin}"
11170 );
11171 }
11172 }
11173
11174 #[test]
11175 fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11176 let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11177 "ssh_url":"git@github.com:someone/project.git",
11178 "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11179 let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11180 "sshUrl":"git@github.com:someone/project.git",
11181 "url":"https://github.com/someone/project"}]"#;
11182
11183 let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11184 assert_eq!("git@github-work:someone/project.git", fork.url);
11185 assert_eq!("someone", fork.owner);
11186 assert_eq!("someone/project", fork.full_name);
11187 let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11188 .unwrap()
11189 .unwrap();
11190 assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11191 assert_eq!(
11192 None,
11193 fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11194 );
11195
11196 let fork = fork_from_api(made, "https://github.com/project").unwrap();
11199 assert_eq!("https://github.com/someone/project.git", fork.url);
11200 let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11201 assert_eq!("git@github.com:someone/project.git", fork.url);
11202
11203 assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11204 assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11205 }
11206
11207 #[test]
11208 fn only_the_viewers_own_fork_can_be_pushed_to() {
11209 let pr = |owner: &str, name: &str| PrView {
11210 number: 9,
11211 url: String::new(),
11212 title: String::new(),
11213 body: String::new(),
11214 head_ref_name: "issue-7".into(),
11215 base_ref_name: "main".into(),
11216 state: "OPEN".into(),
11217 closing_issues_references: Vec::new(),
11218 is_cross_repository: true,
11219 head_repository_owner: Some(crate::model::Author {
11220 login: owner.into(),
11221 }),
11222 head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11223 };
11224 let origin = "git@github.com:example/project.git";
11225
11226 assert_eq!(
11227 Some(PushTarget::Fork(Fork {
11228 owner: "Someone".into(),
11229 full_name: "Someone/project".into(),
11230 url: "git@github.com:Someone/project.git".into(),
11231 })),
11232 own_fork_target(&pr("Someone", "project"), "someone", origin)
11233 );
11234 assert_eq!(
11235 None,
11236 own_fork_target(&pr("other", "project"), "someone", origin)
11237 );
11238 assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11239 assert_eq!(
11240 None,
11241 own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11242 );
11243 let mut unnamed = pr("someone", "project");
11244 unnamed.head_repository_owner = None;
11245 assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11246 }
11247
11248 #[cfg(unix)]
11251 #[test]
11252 fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11253 let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11254 test_git(
11255 &work,
11256 &[
11257 "push",
11258 "-q",
11259 fork.to_str().unwrap(),
11260 "issue-7:refs/heads/issue-7",
11261 ],
11262 );
11263 let published = test_git(&work, &["rev-parse", "issue-7"]);
11264 test_git(&work, &["checkout", "-q", "main"]);
11265 let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11266 let pr = PrView {
11267 number: 31,
11268 url: String::new(),
11269 title: String::new(),
11270 body: String::new(),
11271 head_ref_name: "issue-7".into(),
11272 base_ref_name: "main".into(),
11273 state: "OPEN".into(),
11274 closing_issues_references: Vec::new(),
11275 is_cross_repository: true,
11276 head_repository_owner: None,
11277 head_repository: None,
11278 };
11279
11280 let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11281
11282 assert_eq!("issue-7", branch);
11283 assert_eq!(
11284 published.trim(),
11285 test_git(&path, &["rev-parse", "HEAD"]).trim()
11286 );
11287 std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11288 test_git(&path, &["commit", "-qam", "Answer a comment"]);
11289 repo.push_to(&path, &target, &branch, Some(published.trim()))
11290 .unwrap();
11291 assert_eq!(
11292 test_git(&path, &["rev-parse", "HEAD"]).trim(),
11293 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11294 );
11295 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11296 .trim()
11297 .is_empty());
11298 }
11299
11300 #[cfg(unix)]
11301 #[test]
11302 fn with_forking_off_a_refused_push_says_why_and_how() {
11303 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11304
11305 let err = repo
11306 .publish_new_branch(&work, "issue-7", "main")
11307 .unwrap_err()
11308 .to_string();
11309
11310 assert!(
11311 err.contains("Permission to example/project.git denied to someone."),
11312 "{err}"
11313 );
11314 assert!(err.contains("fork_when_denied = true"), "{err}");
11315 assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11316 .trim()
11317 .is_empty());
11318 assert_eq!(
11319 WriteSummary {
11320 attempted: 1,
11321 failed: 1
11322 },
11323 repo.write_summary()
11324 );
11325 }
11326}
11327
11328#[cfg(test)]
11329mod comment_page_tests {
11330 use super::*;
11331
11332 #[test]
11333 fn a_single_merged_array_is_read() {
11334 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11335 assert_eq!(2, pages.len());
11336 assert_eq!(Some(2), pages[1]["id"].as_i64());
11337 }
11338
11339 #[test]
11340 fn concatenated_pages_from_an_older_gh_are_read_too() {
11341 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11342 assert_eq!(2, pages.len());
11343 }
11344
11345 #[test]
11349 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11350 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11351 let pages = parse_comment_pages(text);
11352 assert_eq!(2, pages.len(), "{pages:?}");
11353 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11354 }
11355
11356 #[test]
11357 fn empty_output_is_no_comments_not_a_panic() {
11358 assert!(parse_comment_pages("").is_empty());
11359 assert!(parse_comment_pages(" ").is_empty());
11360 assert!(parse_comment_pages("[]").is_empty());
11361 }
11362
11363 #[test]
11364 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11365 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11366 }
11367
11368 #[test]
11369 fn a_write_postcheck_rejects_truncated_comment_pages() {
11370 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11371 assert!(
11372 error.to_string().contains("unexpected comment pages"),
11373 "{error}"
11374 );
11375 }
11376
11377 #[test]
11378 fn a_write_postcheck_rejects_empty_or_non_array_output() {
11379 assert!(try_parse_comment_pages("").is_err());
11380 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11381 assert!(try_parse_comment_pages("[]").is_ok());
11382 }
11383
11384 #[test]
11385 fn state_is_found_in_the_last_matching_comment() {
11386 let payload = |round: u32| {
11387 format!(
11388 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11389 )
11390 };
11391 let text = serde_json::to_string(&serde_json::json!([
11392 {"id": 1, "body": payload(1)},
11393 {"id": 2, "body": "looks good to me"},
11394 {"id": 3, "body": payload(5)},
11395 ]))
11396 .unwrap();
11397 let pages = parse_comment_pages(&text);
11398 let last = pages
11399 .iter()
11400 .rev()
11401 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11402 .unwrap();
11403 assert_eq!(5, last.round);
11404 }
11405}
11406
11407#[cfg(test)]
11408mod linked_pr_tests {
11409 use super::*;
11410
11411 const REAL_PAYLOAD: &str = r#"[
11416 {"number":14252,"title":"fix: reject leading-dash branch names",
11417 "url":"https://github.com/cli/cli/pull/14252",
11418 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11419 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11420 "url":"https://github.com/cli/cli/issues/14238"}]},
11421 {"number":14217,"title":"another change",
11422 "url":"https://github.com/cli/cli/pull/14217",
11423 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11424 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11425 "url":"https://github.com/cli/cli/issues/9761"}]},
11426 {"number":14200,"title":"unlinked work",
11427 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11428 ]"#;
11429
11430 #[test]
11431 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11432 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11433 assert_eq!(14252, pr.number);
11434 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11435 }
11436
11437 #[test]
11438 fn the_right_pr_is_picked_out_of_several() {
11439 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11440 }
11441
11442 #[test]
11443 fn an_issue_nobody_is_working_on_finds_nothing() {
11444 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11445 }
11446
11447 #[test]
11448 fn an_unlinked_pr_is_never_matched() {
11449 for issue in [14200, 0, 1] {
11451 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11452 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11453 }
11454 }
11455 }
11456
11457 #[test]
11458 fn empty_or_broken_output_is_none_rather_than_a_panic() {
11459 assert!(find_linked_pr("", 1).is_none());
11460 assert!(find_linked_pr("[]", 1).is_none());
11461 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11462 assert!(find_linked_pr("[{\"number\":", 1).is_none());
11463 }
11464
11465 #[test]
11472 fn an_unreadable_list_is_not_an_empty_list() {
11473 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11474 assert!(linked_pr_rows("[{\"number\":").is_none());
11475 assert!(linked_pr_rows("").is_none());
11476
11477 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11478 assert!(empty.is_empty());
11479 assert!(pick_linked_pr(&empty, 14238).is_none());
11480
11481 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11482 assert_eq!(3, listed.len());
11483 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11484 }
11485
11486 #[test]
11488 fn pr_view_reads_the_cross_repository_flag() {
11489 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11490 "baseRefName":"main","state":"OPEN",
11491 "closingIssuesReferences":[],"isCrossRepository":true}"#;
11492 let pr: PrView = serde_json::from_str(json).unwrap();
11493 assert!(pr.is_cross_repository);
11494 assert!(pr.is_open());
11495
11496 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11497 assert!(
11498 !serde_json::from_str::<PrView>(&same_repo)
11499 .unwrap()
11500 .is_cross_repository
11501 );
11502 }
11503}
11504
11505#[cfg(test)]
11506mod min_number_tests {
11507 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11513 let mut numbers: Vec<i64> = open.to_vec();
11514 numbers.sort_unstable();
11515 if min_number > 0 {
11516 numbers.retain(|n| *n >= min_number);
11517 }
11518 numbers.truncate(limit);
11519 numbers
11520 }
11521
11522 #[test]
11523 fn the_floor_is_applied_before_the_cap_not_after() {
11524 let open = [12, 13, 14, 480, 481, 482];
11525 assert_eq!(vec![480, 481], pick(&open, 2, 480));
11526 assert!(!pick(&open, 2, 480).is_empty());
11529 }
11530
11531 #[test]
11532 fn no_floor_keeps_the_old_behaviour() {
11533 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11534 }
11535
11536 #[test]
11537 fn the_floor_is_inclusive() {
11538 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11539 }
11540
11541 #[test]
11542 fn a_floor_above_everything_open_yields_nothing() {
11543 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11544 }
11545}
11546
11547#[cfg(test)]
11548mod review_record_tests {
11549 use super::*;
11550 use serde_json::json;
11551
11552 #[test]
11553 fn prune_state_knows_which_files_belong_to_a_pull_request() {
11554 assert_eq!(Some(12), state_file_number("pr-12.json"));
11555 assert_eq!(Some(12), state_file_number("review-12.json"));
11556 assert_eq!(Some(12), state_file_number("checkin-12.json"));
11557 for shared in [
11558 "spend.json",
11559 "implementors.json",
11560 "contested.json",
11561 "listen.json",
11562 "pr-.json",
11563 "pr-12.json.tmp",
11564 "pr-x.json",
11565 ] {
11566 assert_eq!(None, state_file_number(shared), "{shared}");
11567 }
11568 }
11569
11570 #[test]
11571 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11572 let reviews = vec![
11573 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11574 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11575 ];
11576 assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11577 assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11578 assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11579 }
11580
11581 #[test]
11585 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11586 let older = vec![
11587 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11588 ];
11589 assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11590 let dismissed = vec![
11591 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11592 ];
11593 assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11594 }
11595
11596 #[test]
11597 fn a_response_splits_into_status_headers_and_body() {
11598 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";
11599 let resp = parse_api_response(text).expect("a response");
11600 assert_eq!(200, resp.status);
11601 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11602 assert_eq!(
11603 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11604 resp.headers.get("last-modified")
11605 );
11606 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11607
11608 let not_modified =
11609 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11610 assert_eq!(304, not_modified.status);
11611 assert!(not_modified.body.is_empty());
11612
11613 assert_eq!(None, parse_api_response(""));
11614 assert_eq!(None, parse_api_response("gh: command not found\n"));
11615 }
11616
11617 #[test]
11618 fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11619 let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11620 {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11621 {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11622 {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11623 {}
11624 ]}}}}})
11625 .to_string();
11626 let made = parse_review_requests(&text).expect("requests");
11627 assert_eq!(2, made.len());
11628 assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11629 assert_eq!("viewer", made[0].who.login);
11630 assert!(made[0].who.slug.is_empty());
11631 assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11632 assert!(made[1].who.login.is_empty());
11633 assert_eq!("example/ours", made[1].who.slug);
11634
11635 let none =
11636 json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11637 assert_eq!(
11638 Some(0),
11639 parse_review_requests(&none.to_string()).map(|m| m.len())
11640 );
11641 assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11642 assert!(parse_review_requests("gh: not json").is_none());
11643 }
11644
11645 #[test]
11646 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11647 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11648 let error = crate::error::SparError::call_failed("gh: boom");
11649 assert!(
11650 reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11651 );
11652
11653 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11654 let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11655 .unwrap_err();
11656 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11657
11658 let unreadable = Err(crate::error::SparError::new("no network"));
11659 let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11660 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11661 assert!(err.to_string().contains("could not be verified"), "{err}");
11662 }
11663 #[test]
11664 fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
11665 let comments = vec![
11666 InlineComment {
11667 path: "src/a.rs".into(),
11668 line: 12,
11669 start_line: None,
11670 body: "one".into(),
11671 },
11672 InlineComment {
11673 path: "src/b.rs".into(),
11674 line: 9,
11675 start_line: Some(4),
11676 body: "two".into(),
11677 },
11678 ];
11679 assert_eq!(
11680 json!({
11681 "commit_id": "abc",
11682 "event": "COMMENT",
11683 "body": "the body",
11684 "comments": [
11685 {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
11686 {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
11687 "start_side": "RIGHT", "body": "two"},
11688 ],
11689 }),
11690 review_payload("abc", "COMMENT", "the body", &comments)
11691 );
11692 }
11693
11694 #[test]
11695 fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
11696 let diff = r#"diff --git a/src/a.rs b/src/a.rs
11697index 1111111..2222222 100644
11698--- a/src/a.rs
11699+++ b/src/a.rs
11700@@ -1,3 +1,4 @@ fn x
11701 one
11702+++ an added line that looks like a header
11703 two
11704 three
11705@@ -20 +21,2 @@
11706-old
11707+new
11708+more
11709@@ -30,1 +32,0 @@
11710-gone
11711diff --git a/gone.rs b/gone.rs
11712deleted file mode 100644
11713--- a/gone.rs
11714+++ /dev/null
11715@@ -1,2 +0,0 @@
11716-a
11717-b
11718diff --git a/old.rs b/renamed.rs
11719similarity index 90%
11720rename from old.rs
11721rename to renamed.rs
11722--- a/old.rs
11723+++ b/renamed.rs
11724@@ -5,0 +6,1 @@
11725+inserted
11726diff --git a/with space.rs b/with space.rs
11727--- a/with space.rs
11728+++ b/with space.rs
11729@@ -2 +2 @@
11730-x
11731+y
11732diff --git "a/sp\tace.rs" "b/sp\tace.rs"
11733--- "a/sp\tace.rs"
11734+++ "b/sp\tace.rs"
11735@@ -1 +1 @@
11736-x
11737+y
11738"#;
11739 let hunks = new_side_hunks(diff);
11740 assert_eq!(
11741 Hunks::from([
11742 ("renamed.rs".to_string(), vec![(6, 6)]),
11743 ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
11744 ("with space.rs".to_string(), vec![(2, 2)]),
11745 ]),
11746 hunks
11747 );
11748 }
11749
11750 #[test]
11751 fn only_a_refusal_is_final_enough_to_send_a_review_again() {
11752 let error = |text: &str| crate::error::SparError::new(text);
11753 assert!(refused(&error(
11754 "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
11755 )));
11756 assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
11757 assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
11758 assert!(!refused(&error("command timed out after 300s")));
11759 }
11760
11761 #[test]
11762 fn a_hunk_header_with_its_counts_left_out_counts_one() {
11763 assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
11764 assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
11765 assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
11766 assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
11767 }
11768}