1use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20 Contested, ContestedItem, Followup, 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
136#[derive(Debug, Clone)]
137pub struct SplitPushError {
138 message: String,
139 retain_worktree: bool,
140}
141
142impl SplitPushError {
143 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
144 Self {
145 message: message.into(),
146 retain_worktree,
147 }
148 }
149
150 pub fn retain_worktree(&self) -> bool {
151 self.retain_worktree
152 }
153}
154
155impl std::fmt::Display for SplitPushError {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.write_str(&self.message)
158 }
159}
160
161impl std::error::Error for SplitPushError {}
162
163fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
168 match attempt {
169 1 => format!("split-{parent}-{index}"),
170 n => format!("split-{parent}-{index}-{n}"),
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum PushTarget {
177 Origin,
178 Fork(Fork),
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Fork {
186 pub owner: String,
189 pub full_name: String,
191 pub url: String,
194}
195
196impl PushTarget {
197 pub fn head(&self, branch: &str) -> String {
199 match self {
200 PushTarget::Origin => branch.to_string(),
201 PushTarget::Fork(fork) => format!("{}:{branch}", fork.owner),
202 }
203 }
204
205 fn remote(&self) -> &str {
206 match self {
207 PushTarget::Origin => "origin",
208 PushTarget::Fork(fork) => &fork.url,
209 }
210 }
211
212 fn describe(&self, branch: &str) -> String {
213 match self {
214 PushTarget::Origin => format!("origin/{branch}"),
215 PushTarget::Fork(fork) => format!("{branch} on the fork {}", fork.full_name),
216 }
217 }
218}
219
220#[derive(Debug)]
221pub struct Repo {
222 root: PathBuf,
223 pub style: Style,
224 pub branch_prefix: String,
225 pub state_store: StateStore,
226 pub followups: Followups,
227 pub drafts: Drafts,
228 pub fork_when_denied: bool,
230 fork: OnceLock<Fork>,
233 viewer: OnceLock<String>,
239 checkpoints: Mutex<BTreeMap<i64, u64>>,
244 writes: WriteStats,
245}
246
247#[derive(Debug, Default)]
248struct WriteStats {
249 attempted: AtomicUsize,
250 failed: AtomicUsize,
251}
252
253#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
254pub(crate) struct WriteSummary {
255 pub(crate) attempted: usize,
256 pub(crate) failed: usize,
257}
258
259impl WriteSummary {
260 pub(crate) fn succeeded(self) -> usize {
261 self.attempted.saturating_sub(self.failed)
262 }
263}
264
265#[derive(Debug, Clone)]
271pub(crate) struct WorktreeBaseline {
272 attributes: AttributeState,
273 ignored_untracked: IgnoredState,
274 git_state: GitState,
275}
276
277#[derive(Debug, Clone)]
283pub(crate) struct WorktreeCheckpoint {
284 path: PathBuf,
285 attributes: AttributeState,
286 git_state: GitState,
287 ignored_untracked: IgnoredState,
288}
289
290#[derive(Debug, Clone, Default, PartialEq, Eq)]
291pub(crate) struct AttributeState {
292 files: BTreeMap<PathBuf, [u8; 32]>,
293}
294
295#[derive(Debug, Clone, Default, PartialEq, Eq)]
300pub(crate) struct IgnoredState {
301 files: BTreeMap<PathBuf, UntrackedFile>,
302 ignored: BTreeSet<PathBuf>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
312struct UntrackedFile {
313 kind: u8,
314 len: u64,
315 modified: Option<std::time::SystemTime>,
316 created: Option<std::time::SystemTime>,
317 readonly: bool,
318 symlink_target: Option<Vec<u8>>,
319 #[cfg(unix)]
320 device: u64,
321 #[cfg(unix)]
322 inode: u64,
323 #[cfg(unix)]
324 mode: u32,
325 #[cfg(unix)]
326 change_seconds: i64,
327 #[cfg(unix)]
328 change_nanoseconds: i64,
329}
330
331#[derive(Debug, Clone, Default, PartialEq, Eq)]
332pub(crate) struct GitState {
333 repositories: BTreeMap<PathBuf, RepositoryState>,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq)]
337struct RepositoryState {
338 head: String,
339 unsafe_index_flags: Vec<u8>,
340 tracked: BTreeMap<PathBuf, TrackedEntry>,
341 gitlinks: BTreeMap<PathBuf, String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345struct TrackedEntry {
346 index_mode: String,
347 index_oid: String,
348 worktree: Option<WorktreeFile>,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
352struct WorktreeFile {
353 mode: String,
354 #[cfg(unix)]
355 permissions: u32,
356 raw_oid: String,
357 fingerprint: [u8; 32],
358 content: [u8; 32],
359}
360
361struct Gitlink {
362 path: PathBuf,
363 oid: String,
364}
365
366struct IndexEntry {
367 path: PathBuf,
368 mode: String,
369 oid: String,
370}
371
372impl IgnoredState {
373 fn is_ignored(&self, path: &Path) -> bool {
374 self.ignored.contains(path)
375 }
376
377 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
378 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
379 paths.extend(after.files.keys().cloned());
380 paths
381 .into_iter()
382 .filter(|path| {
383 self.files.get(path) != after.files.get(path)
384 || self.is_ignored(path) != after.is_ignored(path)
385 })
386 .collect()
387 }
388
389 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
390 self.files
391 .iter()
392 .filter(|(path, state)| after.files.get(*path) != Some(*state))
393 .map(|(path, _)| path.clone())
394 .collect()
395 }
396
397 fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
398 after
399 .files
400 .keys()
401 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
402 .cloned()
403 .collect()
404 }
405
406 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
416 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
417 paths.extend(after.files.keys());
418 paths.into_iter().any(|path| {
419 if self.files.get(path) == after.files.get(path)
420 && self.is_ignored(path) == after.is_ignored(path)
421 {
422 return false;
423 }
424 !self.generated_move(after, path)
425 })
426 }
427
428 pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
456 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
457 paths.extend(after.files.keys());
458 let mut leavings = ReadOnlyLeavings::default();
459 for path in paths {
460 if self.files.get(path) == after.files.get(path)
461 && self.is_ignored(path) == after.is_ignored(path)
462 {
463 continue;
464 }
465 if !self.ignored_file_move(after, path) {
466 leavings.condemning.push(path.clone());
467 } else if !is_generated_artifact(path) {
468 leavings.tolerated.push(path.clone());
469 }
470 }
471 leavings
472 }
473
474 fn generated_move(&self, after: &Self, path: &Path) -> bool {
488 is_generated_artifact(path) && self.disposable_move(after, path)
489 }
490
491 fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
495 if path.starts_with(WORKTREE_DIR)
496 && self.files.get(path).is_some_and(|file| file.kind == 3)
497 && self.files.get(path) != after.files.get(path)
498 {
499 return false;
500 }
501 self.disposable_move(after, path)
502 }
503
504 fn disposable_move(&self, after: &Self, path: &Path) -> bool {
507 self.disposable(path) && after.disposable(path)
508 }
509
510 fn disposable(&self, path: &Path) -> bool {
513 !self.files.contains_key(path) || self.is_ignored(path)
514 }
515}
516
517impl GitState {
518 pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
524 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
525 prefixes.extend(after.repositories.keys());
526 let mut notes: Vec<String> = Vec::new();
527 let mut paths: Vec<PathBuf> = Vec::new();
528 for prefix in prefixes {
529 let label = describe_repository(prefix);
530 match (
531 self.repositories.get(prefix),
532 after.repositories.get(prefix),
533 ) {
534 (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
535 (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
536 (Some(before), Some(current)) => {
537 if before.head != current.head {
538 notes.push(format!("HEAD moved in {label}"));
539 }
540 if before.unsafe_index_flags != current.unsafe_index_flags {
541 notes.push(format!("index flags changed in {label}"));
542 }
543 paths.extend(
544 differing_keys(&before.gitlinks, ¤t.gitlinks)
545 .chain(differing_keys(&before.tracked, ¤t.tracked))
546 .map(|path| prefix.join(path)),
547 );
548 }
549 (None, None) => {}
550 }
551 }
552 if !paths.is_empty() {
553 paths.sort();
554 paths.dedup();
555 notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
556 }
557 (!notes.is_empty()).then(|| notes.join("; "))
558 }
559
560 pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
563 let difference = self.describe_difference(after)?;
564 let Some(moved) = self.moved_wholesale(after) else {
565 return Some(difference);
566 };
567 if said_once("moved", cwd, &moved) {
570 logwarn!(
571 "the checkout at {} moved while a read-only call was reading it: {moved}. The \
572 answer was kept, and it describes the tree as it stood before the move.",
573 cwd.display()
574 );
575 }
576 None
577 }
578
579 fn moved_wholesale(&self, after: &Self) -> Option<String> {
596 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
597 prefixes.extend(after.repositories.keys());
598 let mut notes: Vec<String> = Vec::new();
599 for prefix in prefixes {
600 let before = self.repositories.get(prefix)?;
602 let current = after.repositories.get(prefix)?;
603 if before.unsafe_index_flags != current.unsafe_index_flags
604 || before.gitlinks != current.gitlinks
605 {
606 return None;
607 }
608 let moved: Vec<&PathBuf> = differing_keys(&before.tracked, ¤t.tracked).collect();
609 if before.head == current.head {
610 if moved.is_empty() {
611 continue;
612 }
613 return None;
614 }
615 if !moved.iter().all(|path| {
616 checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
617 }) {
618 return None;
619 }
620 let label = describe_repository(prefix);
621 notes.push(match moved.len() {
622 0 => format!("HEAD moved in {label}"),
623 count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
624 });
625 }
626 (!notes.is_empty()).then(|| notes.join("; "))
627 }
628}
629
630fn checked_out(entry: Option<&TrackedEntry>) -> bool {
637 entry.is_none_or(|entry| {
638 entry
639 .worktree
640 .as_ref()
641 .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
642 })
643}
644
645pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
656 if paths.is_empty() {
657 return;
658 }
659 let named = list_paths(paths);
660 if !said_once("ignored", cwd, &named) {
661 return;
662 }
663 logdim!(
664 "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
665 project ignores these paths.",
666 paths.len(),
667 cwd.display()
668 );
669}
670
671fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
678 static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
679 SAID.lock()
680 .unwrap_or_else(std::sync::PoisonError::into_inner)
681 .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
682}
683
684fn describe_repository(prefix: &Path) -> String {
687 if prefix.as_os_str().is_empty() {
688 "the worktree".to_string()
689 } else {
690 format!("the submodule at {:?}", prefix.as_os_str())
691 }
692}
693
694fn differing_keys<'a, V: PartialEq>(
696 before: &'a BTreeMap<PathBuf, V>,
697 after: &'a BTreeMap<PathBuf, V>,
698) -> impl Iterator<Item = &'a PathBuf> {
699 let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
700 keys.extend(after.keys());
701 keys.into_iter()
702 .filter(move |key| before.get(*key) != after.get(*key))
703}
704
705#[derive(Default)]
711pub(crate) struct ReadOnlyLeavings {
712 pub(crate) tolerated: Vec<PathBuf>,
716 pub(crate) condemning: Vec<PathBuf>,
718}
719
720pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
724 match (first, second) {
725 (None, None) => None,
726 (Some(one), None) | (None, Some(one)) => Some(one),
727 (Some(first), Some(second)) => Some(format!("{first}; {second}")),
728 }
729}
730
731pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
736 const SHOWN: usize = 5;
737 let mut listed = paths
738 .iter()
739 .take(SHOWN)
740 .map(|path| format!("{:?}", path.as_os_str()))
741 .collect::<Vec<_>>()
742 .join(", ");
743 if paths.len() > SHOWN {
744 listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
745 }
746 listed
747}
748
749#[derive(Default)]
756struct IgnoredArtifacts {
757 new_paths: BTreeSet<PathBuf>,
758 changed_paths: BTreeSet<PathBuf>,
759 removed_paths: BTreeSet<PathBuf>,
760}
761
762impl IgnoredArtifacts {
763 fn left(&mut self, allowed: AllowedArtifacts) {
764 self.new_paths.extend(allowed.present);
765 self.removed_paths.extend(allowed.removed);
766 }
767
768 fn changed(&mut self, allowed: AllowedArtifacts) {
769 self.changed_paths.extend(allowed.present);
770 self.removed_paths.extend(allowed.removed);
771 }
772
773 fn report(&self, cwd: &Path) {
775 let paths: BTreeSet<_> = self
776 .new_paths
777 .iter()
778 .chain(&self.changed_paths)
779 .chain(&self.removed_paths)
780 .cloned()
781 .collect();
782 if !paths.is_empty() {
783 logdim!(
784 "the editing call changed {} ignored file(s) in {}: {}. They are outside \
785 the commit; cleanup checks them separately.",
786 paths.len(),
787 cwd.display(),
788 list_paths(&paths.into_iter().collect::<Vec<_>>())
789 );
790 }
791 }
792}
793
794#[derive(Default)]
798struct AllowedArtifacts {
799 present: Vec<PathBuf>,
800 removed: Vec<PathBuf>,
801}
802
803impl AllowedArtifacts {
804 fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
805 let (present, removed) = paths
806 .into_iter()
807 .partition(|path| after.files.contains_key(path));
808 Self { present, removed }
809 }
810}
811
812fn is_generated_artifact(path: &Path) -> bool {
818 const DIRECTORIES: &[&str] = &[
819 "target",
820 "dist",
821 "node_modules",
822 "__pycache__",
823 ".pytest_cache",
824 ".mypy_cache",
825 ".ruff_cache",
826 ".tox",
827 ".nox",
828 ".venv",
829 "venv",
830 ".gradle",
831 ".build",
832 "DerivedData",
833 ".next",
834 ".nuxt",
835 ".svelte-kit",
836 ".turbo",
837 "coverage",
838 ];
839 path.components().any(|component| {
840 let std::path::Component::Normal(name) = component else {
841 return false;
842 };
843 DIRECTORIES
844 .iter()
845 .any(|directory| name == OsStr::new(directory))
846 })
847}
848
849fn is_desktop_metadata(path: &Path) -> bool {
855 path.file_name() == Some(OsStr::new(".DS_Store"))
856}
857
858fn merge_pr_args<'a>(
859 number: &'a str,
860 expected_head: Option<&'a str>,
861 delete_branch: bool,
862) -> Vec<&'a str> {
863 let mut args = vec!["pr", "merge", number, "--squash"];
864 if delete_branch {
865 args.push("--delete-branch");
866 }
867 if let Some(expected_head) = expected_head {
868 args.extend(["--match-head-commit", expected_head]);
869 }
870 args
871}
872
873fn push_denial(message: &str) -> Option<&str> {
883 message
884 .lines()
885 .map(|line| {
886 let line = line.trim();
887 let line = line.strip_prefix("ERROR:").unwrap_or(line);
888 line.strip_prefix("remote:").unwrap_or(line).trim()
889 })
890 .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
891}
892
893fn push_rejection(message: &str) -> Option<&str> {
896 message
897 .lines()
898 .map(|line| line.trim().trim_start_matches('!').trim())
899 .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
900}
901
902fn push_error(
903 target: &PushTarget,
904 branch: &str,
905 e: &crate::error::SparError,
906) -> crate::error::SparError {
907 let message = e.to_string();
908 if let Some(denied) = push_denial(&message) {
909 return spar_err!("could not push to {}. {denied}", target.describe(branch));
910 }
911 spar_err!(
912 "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
913 target.describe(branch),
914 push_rejection(&message).unwrap_or(e.last_line())
915 )
916}
917
918fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
927 let origin = origin_url.trim();
928 let path_start = match origin.find("://") {
929 Some(scheme) => {
930 let host = scheme + 3;
931 host + origin[host..].find('/')? + 1
932 }
933 None => {
934 let colon = origin.find(':')?;
937 if origin[..colon].contains('/') {
938 return None;
939 }
940 colon + 1
941 }
942 };
943 let path = origin[path_start..].trim_end_matches('/');
944 let (path, suffix) = match path.strip_suffix(".git") {
945 Some(bare) => (bare, ".git"),
946 None => (path, ""),
947 };
948 let segments: Vec<&str> = path.split('/').collect();
949 if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
950 return None;
951 }
952 let prefix = &segments[..segments.len() - 2];
953 let prefix = if prefix.is_empty() {
954 String::new()
955 } else {
956 format!("{}/", prefix.join("/"))
957 };
958 Some(format!(
959 "{}{prefix}{full_name}{suffix}",
960 &origin[..path_start]
961 ))
962}
963
964fn fork_reached_like_origin(
969 owner: String,
970 full_name: String,
971 origin_url: &str,
972 ssh_url: String,
973 https_url: String,
974) -> Fork {
975 let origin = origin_url.trim();
976 let url = fork_url(origin, &full_name).unwrap_or_else(|| {
977 if origin.starts_with("git@") || origin.starts_with("ssh://") {
978 ssh_url
979 } else {
980 https_url
981 }
982 });
983 Fork {
984 owner,
985 full_name,
986 url,
987 }
988}
989
990#[derive(Deserialize)]
991struct ForkOwner {
992 login: String,
993}
994
995fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
997 #[derive(Deserialize)]
998 struct Answer {
999 full_name: String,
1000 owner: ForkOwner,
1001 ssh_url: String,
1002 clone_url: String,
1003 }
1004
1005 let answer: Answer = serde_json::from_str(answer.trim())
1006 .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1007 Ok(fork_reached_like_origin(
1008 answer.owner.login,
1009 answer.full_name,
1010 origin_url,
1011 answer.ssh_url,
1012 answer.clone_url,
1013 ))
1014}
1015
1016fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1019 #[derive(Deserialize)]
1020 #[serde(rename_all = "camelCase")]
1021 struct Node {
1022 name_with_owner: String,
1023 owner: ForkOwner,
1024 ssh_url: String,
1025 url: String,
1026 }
1027
1028 let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1029 .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1030 Ok(nodes.into_iter().next().map(|node| {
1031 fork_reached_like_origin(
1032 node.owner.login,
1033 node.name_with_owner,
1034 origin_url,
1035 node.ssh_url,
1036 node.url,
1037 )
1038 }))
1039}
1040
1041fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1044 let owner = pr.head_repository_owner.as_ref()?.login.trim();
1045 let name = pr.head_repository.as_ref()?.name.trim();
1046 if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1047 return None;
1048 }
1049 let full_name = format!("{owner}/{name}");
1050 Some(PushTarget::Fork(Fork {
1051 owner: owner.to_string(),
1052 url: fork_url(origin_url, &full_name)?,
1053 full_name,
1054 }))
1055}
1056
1057fn reconcile_pr_creation(
1058 branch: &str,
1059 created: Result<String>,
1060 found: Result<Option<PrRef>>,
1061) -> Result<PrRef> {
1062 match (created, found) {
1063 (_, Ok(Some(pr))) => Ok(pr),
1064 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1065 "PR creation reported success but none was found for {branch}"
1066 ))),
1067 (Err(create), Ok(None)) => Err(spar_err!(
1068 "could not open a PR for {branch}. {}",
1069 create.last_line()
1070 )),
1071 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1072 "PR creation reported success for {branch}, but it could not be verified. {}",
1073 check.last_line()
1074 ))),
1075 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1076 "could not open a PR for {branch}. {} The result could not be verified: {}",
1077 create.last_line(),
1078 check.last_line()
1079 ))),
1080 }
1081}
1082
1083fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1084 #[derive(Deserialize)]
1085 #[serde(rename_all = "camelCase")]
1086 struct Row {
1087 number: i64,
1088 #[serde(default)]
1089 url: String,
1090 #[serde(default)]
1091 title: String,
1092 base_ref_name: String,
1093 }
1094
1095 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1096 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1097 })?;
1098 Ok(rows
1099 .into_iter()
1100 .find(|row| row.base_ref_name == base)
1101 .map(|row| PrRef {
1102 number: row.number,
1103 url: row.url,
1104 title: row.title,
1105 }))
1106}
1107
1108fn signed(body: &str) -> String {
1111 if body.contains(COMMENT_MARKER) {
1112 return body.to_string();
1113 }
1114 format!("{body}\n\n{COMMENT_MARKER}")
1115}
1116
1117pub fn is_spar_comment(body: &str) -> bool {
1119 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1120}
1121
1122#[derive(Debug, Clone, Default, PartialEq, Eq)]
1124pub struct ApiResponse {
1125 pub status: u16,
1126 pub headers: BTreeMap<String, String>,
1128 pub body: String,
1129}
1130
1131pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1135 let mut lines = stdout.split_inclusive('\n');
1136 let first = lines.next()?.trim_end_matches(['\r', '\n']);
1137 if !first.starts_with("HTTP/") {
1138 return None;
1139 }
1140 let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1141 let mut headers = BTreeMap::new();
1142 let mut body = String::new();
1143 let mut in_body = false;
1144 for line in lines {
1145 if in_body {
1146 body.push_str(line);
1147 continue;
1148 }
1149 let bare = line.trim_end_matches(['\r', '\n']);
1150 if bare.is_empty() {
1151 in_body = true;
1152 continue;
1153 }
1154 if let Some((name, value)) = bare.split_once(':') {
1155 headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1156 }
1157 }
1158 Some(ApiResponse {
1159 status,
1160 headers,
1161 body,
1162 })
1163}
1164
1165const REQUESTS_QUERY: &str = "\
1168query($owner: String!, $repo: String!, $number: Int!) {
1169 repository(owner: $owner, name: $repo) {
1170 pullRequest(number: $number) {
1171 timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1172 nodes {
1173 ... on ReviewRequestedEvent {
1174 createdAt
1175 requestedReviewer {
1176 ... on User { login }
1177 ... on Team { combinedSlug }
1178 }
1179 }
1180 }
1181 }
1182 }
1183 }
1184}";
1185
1186pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1190 let v: Value = serde_json::from_str(text).ok()?;
1191 let nodes = v
1192 .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1193 .as_array()?;
1194 let text_at = |node: &Value, path: &str| {
1195 node.pointer(path)
1196 .and_then(Value::as_str)
1197 .unwrap_or_default()
1198 .to_string()
1199 };
1200 Some(
1201 nodes
1202 .iter()
1203 .map(|node| crate::model::ReviewRequested {
1204 at: text_at(node, "/createdAt"),
1205 who: crate::model::Login {
1206 login: text_at(node, "/requestedReviewer/login"),
1207 slug: text_at(node, "/requestedReviewer/combinedSlug"),
1208 },
1209 })
1210 .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1211 .collect(),
1212 )
1213}
1214
1215pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1219 let stem = name.strip_suffix(".json")?;
1220 ["pr-", "review-", "checkin-"]
1221 .iter()
1222 .find_map(|prefix| stem.strip_prefix(prefix))
1223 .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1224 .and_then(|digits| digits.parse().ok())
1225}
1226
1227fn has_viewer_review(reviews: &[Value], viewer: &str, body: &str, commit: &str) -> bool {
1232 reviews.iter().any(|review| {
1233 let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1234 let standing = !matches!(
1235 field("state").to_uppercase().as_str(),
1236 "DISMISSED" | "PENDING"
1237 );
1238 review
1239 .get("user")
1240 .and_then(|u| u.get("login"))
1241 .and_then(Value::as_str)
1242 .is_some_and(|login| crate::comments::same_login(login, viewer))
1243 && field("body") == body
1244 && field("commit_id").eq_ignore_ascii_case(commit)
1245 && standing
1246 })
1247}
1248
1249fn reconcile_review_post(
1250 number: i64,
1251 body: &str,
1252 viewer: &str,
1253 commit: &str,
1254 post_error: crate::error::SparError,
1255 reviews: Result<Vec<Value>>,
1256) -> Result<()> {
1257 match reviews {
1258 Ok(reviews) if has_viewer_review(&reviews, viewer, body, commit) => Ok(()),
1259 Ok(_) => Err(post_error),
1260 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1261 "could not submit a review on #{number}. {} The result could not be verified: {}",
1262 post_error.last_line(),
1263 read_error.last_line()
1264 ))),
1265 }
1266}
1267
1268fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1273 let comments: Vec<Value> = comments
1274 .iter()
1275 .map(|c| {
1276 let mut row = serde_json::json!({
1277 "path": c.path,
1278 "line": c.line,
1279 "side": "RIGHT",
1280 "body": c.body,
1281 });
1282 if let Some(start) = c.start_line {
1283 row["start_line"] = serde_json::json!(start);
1284 row["start_side"] = serde_json::json!("RIGHT");
1285 }
1286 row
1287 })
1288 .collect();
1289 serde_json::json!({
1290 "commit_id": commit,
1291 "event": event,
1292 "body": body,
1293 "comments": comments,
1294 })
1295}
1296
1297fn refused(e: &crate::error::SparError) -> bool {
1301 e.to_string().contains("(HTTP 422)")
1302}
1303
1304pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1308
1309fn new_side_hunks(diff: &str) -> Hunks {
1316 let mut out = Hunks::new();
1317 let mut path: Option<String> = None;
1318 let (mut old_left, mut new_left) = (0u32, 0u32);
1319 for line in diff.lines() {
1320 if old_left > 0 || new_left > 0 {
1321 match line.as_bytes().first() {
1322 Some(b'+') => new_left = new_left.saturating_sub(1),
1323 Some(b'-') => old_left = old_left.saturating_sub(1),
1324 Some(b'\\') => {}
1325 _ => {
1326 old_left = old_left.saturating_sub(1);
1327 new_left = new_left.saturating_sub(1);
1328 }
1329 }
1330 continue;
1331 }
1332 if line.starts_with("diff --git ") {
1333 path = None;
1334 } else if let Some(rest) = line.strip_prefix("+++ ") {
1335 let rest = if rest.contains(' ') {
1338 rest.strip_suffix('\t').unwrap_or(rest)
1339 } else {
1340 rest
1341 };
1342 path = rest
1343 .strip_prefix("b/")
1344 .filter(|p| !p.is_empty())
1345 .map(str::to_string);
1346 } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1347 old_left = old_len;
1348 new_left = len;
1349 if let (Some(path), true) = (&path, len > 0 && start > 0) {
1350 out.entry(path.clone())
1351 .or_default()
1352 .push((start, start + len - 1));
1353 }
1354 }
1355 }
1356 out
1357}
1358
1359fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1361 let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1362 let (old, new) = ranges.split_once(" +")?;
1363 let range = |s: &str| -> Option<(u32, u32)> {
1364 match s.split_once(',') {
1365 Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1366 None => Some((s.parse().ok()?, 1)),
1367 }
1368 };
1369 Some((range(old)?, range(new)?))
1370}
1371
1372fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1373 comments.iter().any(|comment| {
1374 comment
1375 .get("body")
1376 .and_then(Value::as_str)
1377 .is_some_and(|seen| seen == body)
1378 })
1379}
1380
1381fn reconcile_comment_post(
1382 number: i64,
1383 body: &str,
1384 post_error: crate::error::SparError,
1385 comments: Result<Vec<Value>>,
1386) -> Result<()> {
1387 match comments {
1388 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1389 Ok(_) => Err(post_error),
1390 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1391 "could not comment on #{number}. {} The result could not be verified: {}",
1392 post_error.last_line(),
1393 read_error.last_line()
1394 ))),
1395 }
1396}
1397
1398fn reconcile_issue_edit(
1399 number: i64,
1400 wanted: &str,
1401 edit_error: crate::error::SparError,
1402 observed: Result<String>,
1403) -> Result<()> {
1404 match observed {
1405 Ok(body) if body == wanted => Ok(()),
1406 Ok(_) => Err(spar_err!(
1407 "could not rewrite the body of #{number}. {}",
1408 edit_error.last_line()
1409 )),
1410 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1411 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1412 edit_error.last_line(),
1413 read_error.last_line()
1414 ))),
1415 }
1416}
1417
1418fn issue_url_has_number(url: &str) -> bool {
1419 url.trim()
1420 .rsplit('/')
1421 .next()
1422 .and_then(|tail| tail.parse::<i64>().ok())
1423 .is_some_and(|number| number > 0)
1424}
1425
1426fn reconcile_issue_creation(
1427 title: &str,
1428 created: Result<String>,
1429 found: Result<Option<ExistingIssue>>,
1430) -> Result<String> {
1431 match (created, found) {
1432 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1433 (_, Ok(Some(issue))) => Ok(issue.url),
1434 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1435 "issue creation reported success but no matching issue was found for {title:?}"
1436 ))),
1437 (Err(create), Ok(None)) => Err(spar_err!(
1438 "could not file issue {title:?}. {}",
1439 create.last_line()
1440 )),
1441 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1442 "issue creation reported success for {title:?}, but it could not be verified. {}",
1443 check.last_line()
1444 ))),
1445 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1446 "could not file issue {title:?}. {} The result could not be verified: {}",
1447 create.last_line(),
1448 check.last_line()
1449 ))),
1450 }
1451}
1452
1453fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1454 if output.trim().is_empty() {
1455 return Ok(None);
1456 }
1457 for line in output.lines() {
1458 let mut fields = line.split_whitespace();
1459 let oid = fields.next().unwrap_or_default();
1460 let name = fields.next().unwrap_or_default();
1461 if name == remote_ref && !oid.is_empty() {
1462 return Ok(Some(oid.to_string()));
1463 }
1464 }
1465 Err(spar_err!(
1466 "origin returned an unexpected ref listing for {remote_ref}"
1467 ))
1468}
1469
1470fn reconcile_failed_split_push(
1471 branch: &str,
1472 push_error: crate::error::SparError,
1473 local: Result<String>,
1474 remote: Result<String>,
1475) -> std::result::Result<(), SplitPushError> {
1476 let remote_ref = format!("refs/heads/{branch}");
1477 match (local, remote) {
1478 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1479 Ok(Some(oid)) if oid == local.trim() => Ok(()),
1480 Ok(_) => Err(SplitPushError::new(
1481 format!(
1482 "could not create origin/{branch}. {} The remote branch is absent or points \
1483 somewhere else. Nothing was overwritten.",
1484 push_error.last_line()
1485 ),
1486 false,
1487 )),
1488 Err(check) => Err(SplitPushError::new(
1489 format!(
1490 "could not confirm whether origin/{branch} was created. {} The remote result \
1491 could not be verified: {}",
1492 push_error.last_line(),
1493 check.last_line()
1494 ),
1495 true,
1496 )),
1497 },
1498 (local, remote) => {
1499 let check = match (local, remote) {
1500 (Err(local), Err(remote)) => format!(
1501 "the local commit could not be read: {}; origin could not be read: {}",
1502 local.last_line(),
1503 remote.last_line()
1504 ),
1505 (Err(local), _) => {
1506 format!("the local commit could not be read: {}", local.last_line())
1507 }
1508 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1509 _ => unreachable!(),
1510 };
1511 Err(SplitPushError::new(
1512 format!(
1513 "could not confirm whether origin/{branch} was created. {} The result could \
1514 not be verified because {check}",
1515 push_error.last_line()
1516 ),
1517 true,
1518 ))
1519 }
1520 }
1521}
1522
1523impl Repo {
1524 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1525 let root =
1526 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1527 let inside = proc::run_str(
1530 &["git", "rev-parse", "--is-inside-work-tree"],
1531 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1532 )
1533 .unwrap_or_default();
1534 if inside.trim() != "true" {
1535 bail!("not a git repository: {}", root.display());
1536 }
1537 let repo = Self {
1538 root,
1539 style: cfg.style.clone(),
1540 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1541 state_store: cfg.loop_cfg.state_store,
1542 followups: cfg.loop_cfg.followups,
1543 drafts: cfg.loop_cfg.drafts,
1544 fork_when_denied: cfg.loop_cfg.fork_when_denied,
1545 fork: OnceLock::new(),
1546 viewer: OnceLock::new(),
1547 checkpoints: Mutex::new(BTreeMap::new()),
1548 writes: WriteStats::default(),
1549 };
1550 repo.self_exclude();
1551 Ok(repo)
1552 }
1553
1554 fn self_exclude(&self) {
1562 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1563 let git_dir = git_dir.trim();
1564 if git_dir.is_empty() {
1565 return;
1566 }
1567 let path = Path::new(git_dir).join("info").join("exclude");
1568 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1569
1570 let wanted = [
1575 format!("/{WORKTREE_DIR}/"),
1576 format!("/{STATE_DIR}/"),
1577 format!("{RECOVERY_MARKER_PREFIX}*"),
1578 ];
1579 let missing: Vec<&String> = wanted
1580 .iter()
1581 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1582 .collect();
1583 if missing.is_empty() {
1584 return;
1585 }
1586
1587 use std::io::Write;
1588 if let Some(parent) = path.parent() {
1589 let _ = std::fs::create_dir_all(parent);
1590 }
1591 let mut block = String::new();
1592 if !existing.is_empty() && !existing.ends_with('\n') {
1593 block.push('\n');
1594 }
1595 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1596 for line in missing {
1597 block.push_str(line);
1598 block.push('\n');
1599 }
1600 if let Ok(mut file) = std::fs::OpenOptions::new()
1601 .create(true)
1602 .append(true)
1603 .open(&path)
1604 {
1605 let _ = file.write_all(block.as_bytes());
1606 }
1607 }
1608
1609 pub fn root(&self) -> &Path {
1610 &self.root
1611 }
1612
1613 pub(crate) fn write_summary(&self) -> WriteSummary {
1614 WriteSummary {
1615 attempted: self.writes.attempted.load(Ordering::Relaxed),
1616 failed: self.writes.failed.load(Ordering::Relaxed),
1617 }
1618 }
1619
1620 pub(crate) fn record_write<T, E>(
1621 &self,
1622 result: std::result::Result<T, E>,
1623 ) -> std::result::Result<T, E> {
1624 self.record_write_outcome(result.is_err());
1625 result
1626 }
1627
1628 pub(crate) fn record_failed_write<T, E>(
1629 &self,
1630 result: std::result::Result<T, E>,
1631 ) -> std::result::Result<T, E> {
1632 if result.is_err() {
1633 self.record_write_outcome(true);
1634 }
1635 result
1636 }
1637
1638 fn record_write_outcome(&self, failed: bool) {
1639 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1640 if failed {
1641 self.writes.failed.fetch_add(1, Ordering::Relaxed);
1642 }
1643 }
1644
1645 pub fn clean(&self, text: &str) -> Result<String> {
1651 let out = style::scrub(text, &self.style);
1652 let bad = style::violations(&out, &self.style);
1653 if !bad.is_empty() {
1654 bail!(
1655 "style gate could not clean text ({}): {}",
1656 bad.join(", "),
1657 style::clip(&out, 300)
1658 );
1659 }
1660 Ok(out)
1661 }
1662
1663 pub fn clean_body(&self, text: &str) -> Result<String> {
1665 self.clean(&style::body(text, &self.style))
1666 }
1667
1668 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1670 self.clean(&style::issue_body(text, &self.style))
1671 }
1672
1673 pub fn clean_title(&self, text: &str) -> Result<String> {
1684 Ok(style::title(&self.clean(text)?, &self.style))
1685 }
1686
1687 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1688 let title = self.record_failed_write(self.clean_title(text))?;
1689 if title.trim().is_empty() {
1690 return self.record_failed_write(Err(spar_err!(
1691 "nothing left of the title after cleaning it"
1692 )));
1693 }
1694 Ok(title)
1695 }
1696
1697 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1698 if self.followups == Followups::Issues {
1699 self.clean_nonempty_title_for_write(text)
1700 } else {
1701 self.clean_title(text)
1702 }
1703 }
1704
1705 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1708 ExecOpts::new()
1709 .cwd(cwd.unwrap_or(&self.root))
1710 .check(check)
1711 .timeout_secs(600)
1712 }
1713
1714 pub fn git(&self, args: &[&str]) -> Result<String> {
1715 self.git_at(None, args)
1716 }
1717
1718 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1719 let argv = git_without_maintenance_argv(args);
1720 proc::run(&argv, &self.git_opts(cwd, true))
1721 }
1722
1723 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1729 let argv = git_without_automation_argv(args);
1730 proc::run(
1731 &argv,
1732 &self.git_opts(Some(cwd), true).stop_descendants(true),
1733 )
1734 }
1735
1736 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1737 let argv = git_without_automation_argv(args);
1738 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1739 .map(|output| output.ok())
1740 }
1741
1742 pub fn git_try(&self, args: &[&str]) -> String {
1744 self.git_try_at(None, args)
1745 }
1746
1747 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1748 let argv = git_without_maintenance_argv(args);
1749 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1750 }
1751
1752 pub fn default_branch(&self, configured: &str) -> String {
1755 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1756 match refname.trim().rsplit('/').next() {
1757 Some(name) if !name.is_empty() => name.to_string(),
1758 _ => configured.to_string(),
1759 }
1760 }
1761
1762 pub fn branch_for_issue(&self, issue: i64) -> String {
1770 format!("{}issue-{issue}", self.branch_prefix)
1771 }
1772
1773 pub fn branch_for_pr(&self, number: i64) -> String {
1774 format!("{}pr-{number}", self.branch_prefix)
1775 }
1776
1777 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1786 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1787 }
1788
1789 fn ledger_path(&self) -> PathBuf {
1790 self.root.join(STATE_DIR).join("branches.json")
1791 }
1792
1793 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1794 std::fs::read_to_string(self.ledger_path())
1795 .ok()
1796 .and_then(|text| serde_json::from_str(&text).ok())
1797 .unwrap_or_default()
1798 }
1799
1800 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1801 let mut data = self.known_branches();
1802 data.insert(
1803 branch.to_string(),
1804 BranchRecord {
1805 kind: kind.to_string(),
1806 number,
1807 },
1808 );
1809 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1810 logdim!("could not record branch {branch}: {e}");
1811 }
1812 }
1813
1814 pub fn forget_branch(&self, branch: &str) {
1815 let mut data = self.known_branches();
1816 if data.remove(branch).is_none() {
1817 return;
1818 }
1819 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1820 logdim!("could not update the branch record: {e}");
1821 }
1822 }
1823
1824 fn worktree_path(&self, name: &str) -> PathBuf {
1827 self.root.join(WORKTREE_DIR).join(name)
1828 }
1829
1830 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1832 let branch = self.branch_for_issue(issue);
1833 let path = self.worktree_path(&format!("issue-{issue}"));
1834
1835 self.refuse_issue_branch_rebuild(issue, base)?;
1836 self.refuse_dirty_worktree(
1837 &path,
1838 &format!("worktree for issue #{issue}"),
1839 Disposable::BuildOutput,
1840 )?;
1841
1842 if !self.branch_deletion_is_safe(&branch)? {
1843 bail!(
1844 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1845 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1846 before retrying."
1847 );
1848 }
1849
1850 if !self.remove_worktree_at(&path)? {
1851 bail!(
1852 "the existing worktree for issue #{issue} could not be removed safely. Its \
1853 branch was kept."
1854 );
1855 }
1856 if !self.delete_branch_if_safe(&branch)? {
1857 bail!(
1858 "the existing branch {branch} changed or remained checked out while its \
1859 worktree was being rebuilt. It was kept."
1860 );
1861 }
1862
1863 if let Some(parent) = path.parent() {
1864 std::fs::create_dir_all(parent)
1865 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1866 }
1867
1868 let path_str = path.display().to_string();
1869 let remote_start = format!("origin/{base}");
1870 let created = self
1871 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1872 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1873
1874 created.map_err(|e| {
1877 spar_err!(
1878 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1879 and does `origin` exist?",
1880 e.last_line()
1881 )
1882 })?;
1883 self.record_branch(&branch, "issue", issue);
1884 Ok((path, branch))
1885 }
1886
1887 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1894 let branch = self.branch_for_issue(issue);
1895 let base_remote_ref = format!("refs/heads/{base}");
1896 let base_tracking_ref = format!("refs/remotes/origin/{base}");
1897 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1898 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1899 .map_err(|e| {
1900 spar_err!(
1901 "could not refresh origin/{base} before checking issue #{issue}: {}",
1902 e.last_line()
1903 )
1904 })?;
1905
1906 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1907 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1908 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1909 bail!(
1910 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1911 pull request accounts for them. Rebuilding it would force push over that \
1912 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1913 it, or delete it with `git push origin --delete {branch}` if the remote \
1914 branch is no longer needed."
1915 );
1916 }
1917 }
1918
1919 if self.fork_when_denied && self.origin_refuses_push() {
1922 match self.existing_fork() {
1923 Ok(Some(fork)) => {
1924 self.fork_branch_lease(&fork, &branch, base)?;
1925 }
1926 Ok(None) => {}
1927 Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
1928 }
1929 }
1930
1931 let local_ref = format!("refs/heads/{branch}");
1932 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1933 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1934 let recorded_pr = self
1935 .known_branches()
1936 .get(&branch)
1937 .is_some_and(|record| record.kind == "pr");
1938 let preserved = ahead == 0
1939 || if recorded_pr {
1940 self.local_branch_is_preserved(&branch)?
1941 } else {
1942 self.pull_request_holds(&branch, &local_ref, base)
1943 };
1944 if !preserved {
1945 let listed = self
1946 .commit_lines(&self.root, &local_ref, base)
1947 .iter()
1948 .map(|line| format!(" {line}"))
1949 .collect::<Vec<_>>()
1950 .join("\n");
1951 bail!(
1952 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1953 no pull request preserves them. Rebuilding it would delete the only copy.\n\
1954 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1955 it, or delete it with `git branch -D {branch}` if it is stale."
1956 );
1957 }
1958 }
1959 Ok(())
1960 }
1961
1962 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1963 let live_ref = format!("refs/heads/{branch}");
1964 let tracking_ref = format!("refs/remotes/origin/{branch}");
1965 let listed = self
1966 .git(&["ls-remote", "--heads", "origin", &live_ref])
1967 .map_err(|e| {
1968 spar_err!(
1969 "could not verify whether origin/{branch} still exists: {}",
1970 e.last_line()
1971 )
1972 })?;
1973
1974 if remote_head_oid(&listed, &live_ref)?.is_some() {
1975 let refspec = format!("+{live_ref}:{tracking_ref}");
1976 self.git(&["fetch", "--no-tags", "origin", &refspec])
1977 .map_err(|e| {
1978 spar_err!(
1979 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1980 e.last_line()
1981 )
1982 })?;
1983 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1984 bail!("origin/{branch} was fetched but its tracking ref is missing");
1985 }
1986 return Ok(Some(tracking_ref));
1987 }
1988
1989 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1990 return Ok(None);
1991 }
1992 let expected = self
1993 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1994 .trim()
1995 .to_string();
1996 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1997 .map_err(|e| {
1998 spar_err!(
1999 "could not discard stale origin/{branch} tracking ref safely: {}",
2000 e.last_line()
2001 )
2002 })?;
2003 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2004 bail!(
2005 "origin/{branch} changed while its stale tracking ref was being removed. It was \
2006 kept."
2007 );
2008 }
2009 Ok(None)
2010 }
2011
2012 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2021 self.prs_for_branch(branch)
2022 .iter()
2023 .any(|pr| self.pr_head_holds(pr.number, refname, base))
2024 }
2025
2026 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2027 let head = format!("refs/spar/pr-head/{number}");
2028 let refspec = format!("+refs/pull/{number}/head:{head}");
2029 if self.git(&["fetch", "origin", &refspec]).is_err() {
2030 return false;
2031 }
2032 let held = self.commits_held_by(refname, base, &head);
2033 self.git_try(&["update-ref", "-d", &head]);
2034 held
2035 }
2036
2037 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2038 let argv = vec![
2039 "git".to_string(),
2040 "merge-base".to_string(),
2041 "--is-ancestor".to_string(),
2042 older.to_string(),
2043 newer.to_string(),
2044 ];
2045 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2046 match out.code {
2047 0 => Ok(true),
2048 1 => Ok(false),
2049 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2050 }
2051 }
2052
2053 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2054 let head = format!("refs/spar/pr-head/{number}");
2055 let refspec = format!("+refs/pull/{number}/head:{head}");
2056 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2057 spar_err!(
2058 "could not verify the immutable head of PR #{number}: {}",
2059 e.last_line()
2060 )
2061 })?;
2062 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2063 self.git_try(&["update-ref", "-d", &head]);
2064 held
2065 }
2066
2067 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2068 let text = self.gh(&[
2069 "pr",
2070 "list",
2071 "--head",
2072 branch,
2073 "--state",
2074 "all",
2075 "--json",
2076 "number,url,title",
2077 ])?;
2078 serde_json::from_str(text.trim())
2079 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2080 }
2081
2082 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2083 let branch_ref = format!("refs/heads/{branch}");
2084 if record.kind == "pr" {
2085 return self.pr_head_contains_checked(record.number, &branch_ref);
2086 }
2087 let prs = self.branch_prs_checked(branch)?;
2088 if prs.is_empty() {
2089 return Ok(false);
2090 }
2091 for pr in prs {
2092 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2093 return Ok(true);
2094 }
2095 }
2096 Ok(false)
2097 }
2098
2099 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2100 let local_ref = format!("refs/heads/{branch}");
2101 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2102 return Ok(true);
2103 }
2104 let oid = self
2105 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2106 .trim()
2107 .to_string();
2108 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2109 if !durable_tip {
2110 let remote_ref = format!("refs/heads/{branch}");
2111 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2112 durable_tip = remote.lines().any(|line| {
2113 line.split_whitespace()
2114 .next()
2115 .is_some_and(|remote_oid| remote_oid == oid)
2116 });
2117 }
2118 if !durable_tip {
2119 if let Some(record) = self.known_branches().get(branch) {
2120 durable_tip = self.branch_is_preserved_checked(branch, record)?;
2121 }
2122 }
2123 if !durable_tip {
2124 return Ok(false);
2125 }
2126 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2127 }
2128
2129 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2133 let local_ref = format!("refs/heads/{branch}");
2134 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2135 return Ok(true);
2136 }
2137 let expected = self
2138 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2139 .trim()
2140 .to_string();
2141 if !self.branch_deletion_is_safe(branch)? {
2142 return Ok(false);
2143 }
2144 let checked_out = self
2145 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2146 .lines()
2147 .any(|line| line == format!("branch {local_ref}"));
2148 if checked_out {
2149 return Ok(false);
2150 }
2151 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2152 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2153 }
2154
2155 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2156 let local_ref = review_ref(number);
2157 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2158 return Ok(true);
2159 }
2160 let oid = self
2161 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2162 .trim()
2163 .to_string();
2164 if !self.pr_head_contains_checked(number, &local_ref)? {
2165 return Ok(false);
2166 }
2167 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2168 }
2169
2170 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2171 let local_ref = review_ref(number);
2172 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2173 return Ok(true);
2174 }
2175 let expected = self
2176 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2177 .trim()
2178 .to_string();
2179 if !self.review_ref_deletion_is_safe(number)? {
2180 return Ok(false);
2181 }
2182 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2183 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2184 }
2185
2186 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2190 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2191 self.git_try(&["rev-list", "--count", &range, "--not", other])
2192 .trim()
2193 == "0"
2194 }
2195
2196 pub fn worktree_remove(&self, issue: i64) -> bool {
2197 let path = self.worktree_path(&format!("issue-{issue}"));
2198 match self.remove_worktree_at(&path) {
2199 Ok(removed) => removed,
2200 Err(error) => {
2201 logdim!(
2202 "kept {} because removal did not reach a confirmed quiet point: {}",
2203 path.display(),
2204 error.last_line()
2205 );
2206 false
2207 }
2208 }
2209 }
2210
2211 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2216 let wanted = std::fs::canonicalize(path)
2217 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2218 if wanted != path {
2223 return Ok(false);
2224 }
2225 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2226 let raw = PathBuf::from(value.trim());
2227 let joined = if raw.is_absolute() {
2228 raw
2229 } else {
2230 cwd.join(raw)
2231 };
2232 std::fs::canonicalize(&joined)
2233 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2234 };
2235 let expected =
2236 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2237 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2238 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2239 let expected = resolve(&self.root, &expected)?;
2240 let actual = resolve(path, &actual)?;
2241 let top = resolve(path, &top)?;
2242 Ok(expected == actual && top == wanted)
2243 }
2244
2245 fn remove_worktree_at_with_force(
2251 &self,
2252 path: &Path,
2253 force: bool,
2254 disposable: Disposable,
2255 ) -> Result<bool> {
2256 let existed = path.exists();
2257 if path.exists() {
2258 match self.worktree_belongs_to_repo(path) {
2259 Ok(true) => {}
2260 Ok(false) => {
2261 logdim!(
2262 "kept {} because it is not a worktree owned by this repository",
2263 path.display()
2264 );
2265 return Ok(false);
2266 }
2267 Err(e) => {
2268 logdim!(
2269 "kept {} because its worktree ownership could not be verified: {}",
2270 path.display(),
2271 e.last_line()
2272 );
2273 return Ok(false);
2274 }
2275 }
2276 if !force {
2277 match self.has_recoverable_work(path, disposable) {
2278 Ok(true) => {
2279 logdim!(
2280 "kept {} because it contains recoverable files or repository state",
2281 path.display()
2282 );
2283 return Ok(false);
2284 }
2285 Err(e) => {
2286 logdim!(
2287 "kept {} because its recoverable state could not be checked: {}",
2288 path.display(),
2289 e.last_line()
2290 );
2291 return Ok(false);
2292 }
2293 Ok(false) => {}
2294 }
2295 }
2296 }
2297 let path_str = path.display().to_string();
2303 let command_ok = if force || disposable == Disposable::ReviewOutput {
2304 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2305 } else {
2306 self.git_try_without_automation(&["worktree", "remove", &path_str])?
2307 };
2308 Ok((command_ok || !existed) && !path.exists())
2309 }
2310
2311 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2312 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2313 }
2314
2315 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2317 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2318 }
2319
2320 fn remove_worktree_at_force(&self, path: &Path) -> bool {
2322 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2323 Ok(removed) => removed,
2324 Err(error) => {
2325 logdim!(
2326 "kept {} because removal did not reach a confirmed quiet point: {}",
2327 path.display(),
2328 error.last_line()
2329 );
2330 false
2331 }
2332 }
2333 }
2334
2335 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2340 if path.exists() && !self.worktree_belongs_to_repo(path)? {
2341 bail!(
2342 "{} is not a worktree owned by this repository, so it was kept",
2343 path.display()
2344 );
2345 }
2346 if path.exists() && self.has_recoverable_work(path, disposable)? {
2347 bail!(
2348 "the verified worktree at {} contains recoverable files or repository state. It \
2349 was kept.",
2350 path.display()
2351 );
2352 }
2353 let path_str = path.display().to_string();
2354 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2355 .map_err(|e| {
2356 e.with_message(format!(
2357 "could not remove the verified worktree at {}: {}. It was kept.",
2358 path.display(),
2359 e.last_line()
2360 ))
2361 })?;
2362 Ok(!path.exists())
2363 }
2364
2365 fn refuse_dirty_worktree(
2366 &self,
2367 path: &Path,
2368 label: &str,
2369 disposable: Disposable,
2370 ) -> Result<()> {
2371 if !path.is_dir() {
2372 return Ok(());
2373 }
2374 let has_files = std::fs::read_dir(path)
2375 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2376 .next()
2377 .is_some();
2378 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2379 spar_err!(
2380 "could not verify whether the existing {label} at {} belongs to this repository, \
2381 so it was kept: {}",
2382 path.display(),
2383 e.last_line()
2384 )
2385 })?;
2386 if !owned {
2387 if has_files {
2388 bail!(
2389 "the existing {label} at {} is not a worktree owned by \
2390 this repository. Refusing to remove it.",
2391 path.display()
2392 );
2393 }
2394 return Ok(());
2395 }
2396 if !path.join(".git").exists() {
2397 if has_files {
2398 bail!(
2399 "the existing {label} at {} is not a readable Git worktree and is not empty. \
2400 Refusing to remove it.",
2401 path.display()
2402 );
2403 }
2404 return Ok(());
2405 }
2406 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2407 spar_err!(
2408 "could not verify whether the existing {label} at {} is clean, so it was kept: \
2409 {}",
2410 path.display(),
2411 e.last_line()
2412 )
2413 })?;
2414 if dirty {
2415 bail!(
2416 "the existing {label} contains uncommitted changes or ignored files at {}. \
2417 Rebuilding it would delete those files.\nCommit or recover them before running this \
2418 command again, or use `spar clean --all` if they are not needed.",
2419 path.display()
2420 );
2421 }
2422 Ok(())
2423 }
2424
2425 pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2428 let head = pr.head_ref_name.clone();
2429 if head.trim().is_empty() {
2430 bail!("PR #{} has no head branch to check out", pr.number);
2431 }
2432 let path = self.worktree_path(&format!("pr-{}", pr.number));
2433 let local = self.branch_for_pr(pr.number);
2434
2435 let unfetched = |e: crate::error::SparError| {
2436 spar_err!(
2437 "could not fetch the branch behind PR #{}: {}",
2438 pr.number,
2439 e.last_line()
2440 )
2441 };
2442 let (start, start_ref, shown) = match target {
2445 PushTarget::Origin => {
2446 self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2447 let start = format!("origin/{head}");
2448 (start.clone(), format!("refs/remotes/origin/{head}"), start)
2449 }
2450 PushTarget::Fork(fork) => {
2453 let start_ref = format!("refs/spar/fork/{head}");
2454 let refspec = format!("+refs/heads/{head}:{start_ref}");
2455 self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2456 .map_err(unfetched)?;
2457 (start_ref.clone(), start_ref, target.describe(&head))
2458 }
2459 };
2460 let local_ref = format!("refs/heads/{local}");
2461 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2462 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2463 if unpushed > 0 {
2464 bail!(
2465 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2466 on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2467 {} and push or recover those commits before running this command again.",
2468 pr.number,
2469 path.display()
2470 );
2471 }
2472 }
2473 self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2474 self.refuse_dirty_worktree(
2475 &path,
2476 &format!("worktree for PR #{}", pr.number),
2477 Disposable::BuildOutput,
2478 )?;
2479 if !self.branch_deletion_is_safe(&local)? {
2480 bail!(
2481 "the existing branch {local} has a tip or reflog-only commit that no surviving \
2482 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2483 before retrying."
2484 );
2485 }
2486 if !self.remove_worktree_at(&path)? {
2487 bail!(
2488 "the existing worktree for PR #{} could not be removed safely. Its branch was \
2489 kept.",
2490 pr.number
2491 );
2492 }
2493 if !self.delete_branch_if_safe(&local)? {
2494 bail!(
2495 "the existing branch {local} changed or remained checked out while the PR \
2496 worktree was being rebuilt. It was kept."
2497 );
2498 }
2499
2500 let path_str = path.display().to_string();
2501 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2502 self.record_branch(&local, "pr", pr.number);
2503 Ok((path, head))
2504 }
2505
2506 fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2509 if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2510 return Ok(());
2511 }
2512 let current =
2513 self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2514 if current.trim() != branch
2515 || self.has_uncommitted_changes(path)?
2516 || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2517 {
2518 return Ok(());
2519 }
2520 static NEXT: AtomicUsize = AtomicUsize::new(0);
2521 let (name, retained) = loop {
2522 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2523 let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2524 let retained = self.worktree_path(&name);
2525 if !retained
2526 .try_exists()
2527 .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2528 && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2529 {
2530 break (name, retained);
2531 }
2532 };
2533 self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2536 if let Err(error) = self.git_at_without_automation(
2537 &self.root,
2538 &[
2539 "worktree",
2540 "move",
2541 &path.display().to_string(),
2542 &retained.display().to_string(),
2543 ],
2544 ) {
2545 let rollback =
2548 self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2549 let detail = rollback
2550 .err()
2551 .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2552 .unwrap_or_default();
2553 bail!(
2554 "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2555 retained.display(),
2556 error.last_line()
2557 );
2558 }
2559 logdim!(
2560 "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2561 retained.display()
2562 );
2563 Ok(())
2564 }
2565
2566 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2576 let path = self.worktree_path(&format!("review-{number}"));
2577 let local_ref = review_ref(number);
2578 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2579
2580 self.refuse_review_worktree_changes(number)?;
2581
2582 if let Some(parent) = path.parent() {
2583 std::fs::create_dir_all(parent)
2584 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2585 }
2586 if !self.remove_review_worktree_at(&path)? {
2591 bail!(
2592 "the existing review worktree for PR #{number} could not be removed safely. Its \
2593 reference was kept."
2594 );
2595 }
2596
2597 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2598 spar_err!(
2599 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2600 every pull request, so this usually means the number is wrong or `origin` does \
2601 not point at the repository the PR is on.",
2602 e.last_line()
2603 )
2604 })?;
2605
2606 let path_str = path.display().to_string();
2607 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2608 Ok(path)
2609 }
2610
2611 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2612 let path = self.worktree_path(&format!("review-{number}"));
2613 if !path.is_dir() {
2614 return Ok(());
2615 }
2616 let local_ref = review_ref(number);
2617 if !self.worktree_belongs_to_repo(&path)? {
2618 return Ok(());
2619 }
2620 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2621 bail!(
2622 "the existing review worktree for PR #{number} has no recorded head at \
2623 {local_ref}. Refusing to rebuild {}.",
2624 path.display()
2625 );
2626 }
2627 let worktree_head = self.head_oid_checked(&path)?;
2628 let recorded_head = self
2629 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2630 .trim()
2631 .to_string();
2632 if worktree_head != recorded_head {
2633 bail!(
2634 "the existing review worktree for PR #{number} has a local commit that is not on \
2635 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2636 {} before retrying.",
2637 path.display()
2638 );
2639 }
2640 self.report_recovery_markers(&path);
2641 self.refuse_dirty_worktree(
2642 &path,
2643 &format!("review worktree for PR #{number}"),
2644 Disposable::ReviewOutput,
2645 )?;
2646 Ok(())
2647 }
2648
2649 fn report_recovery_markers(&self, path: &Path) {
2657 let Ok(entries) = std::fs::read_dir(path) else {
2658 return;
2659 };
2660 for entry in entries.flatten() {
2661 let name = entry.file_name();
2662 let Some(name) = name.to_str() else { continue };
2663 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2664 continue;
2665 }
2666 match std::fs::read_to_string(entry.path()) {
2667 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2668 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2669 }
2670 }
2671 }
2672
2673 pub fn worktree_for_split(
2686 &self,
2687 parent: i64,
2688 index: usize,
2689 start: &str,
2690 ) -> Result<(PathBuf, String)> {
2691 let slot = self.free_split_slot(parent, index)?;
2692 let branch = format!("{}{slot}", self.branch_prefix);
2693 let path = self.worktree_path(&slot);
2694
2695 if let Some(dir) = path.parent() {
2696 std::fs::create_dir_all(dir)
2697 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2698 }
2699 self.refuse_dirty_worktree(
2700 &path,
2701 &format!("worktree for part {index} of PR #{parent}"),
2702 Disposable::BuildOutput,
2703 )?;
2704 if !self.remove_worktree_at(&path)? {
2708 bail!(
2709 "the existing worktree for part {index} of PR #{parent} could not be removed \
2710 safely. No branch was created."
2711 );
2712 }
2713
2714 let path_str = path.display().to_string();
2715 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2716 .map_err(|e| {
2717 spar_err!(
2718 "could not create a worktree for part {index} of #{parent}. {}",
2719 e.last_line()
2720 )
2721 })?;
2722 self.record_branch(&branch, "split", parent);
2725 Ok((path, branch))
2726 }
2727
2728 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2735 for attempt in 1..=SPLIT_SLOTS {
2736 let slot = split_slot(parent, index, attempt);
2737 let branch = format!("{}{slot}", self.branch_prefix);
2738 self.git_try(&["fetch", "origin", &branch]);
2739 if !self.rev_exists(&self.root, &branch)
2740 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2741 {
2742 return Ok(slot);
2743 }
2744 }
2745 bail!(
2746 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2747 names are all taken. Inspect the existing branches and child pull requests. Finish \
2748 recording the earlier split, or remove every retained local worktree and branch, \
2749 child pull request, and remote split branch before starting over.",
2750 self.branch_for_split(parent, index)
2751 )
2752 }
2753
2754 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2760 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2761 Ok(!self
2762 .git(&["ls-remote", "--heads", "origin", &pattern])?
2763 .trim()
2764 .is_empty())
2765 }
2766
2767 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2774 match self.branch_deletion_is_safe(branch) {
2775 Ok(true) => {}
2776 Ok(false) => {
2777 logdim!(
2778 "kept {branch} and {} because no surviving ref preserves its tip",
2779 dir.display()
2780 );
2781 return;
2782 }
2783 Err(error) => {
2784 logdim!(
2785 "kept {branch} and {} because preservation could not be verified: {}",
2786 dir.display(),
2787 error.last_line()
2788 );
2789 return;
2790 }
2791 }
2792 match self.remove_worktree_at(dir) {
2793 Ok(true) => match self.delete_branch_if_safe(branch) {
2794 Ok(true) => self.forget_branch(branch),
2795 Ok(false) => {
2796 logdim!("kept {branch} because its tip or reflog changed before deletion")
2797 }
2798 Err(error) => logdim!(
2799 "kept {branch} because deletion safety could not be rechecked: {}",
2800 error.last_line()
2801 ),
2802 },
2803 Ok(false) => {}
2804 Err(error) => logdim!(
2805 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2806 dir.display(),
2807 error.last_line()
2808 ),
2809 }
2810 }
2811
2812 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2818 let record = self.known_branches().get(branch).cloned();
2819 if record.is_none_or(|record| record.kind != "split") {
2820 logdim!("kept {branch} because no split branch record proves ownership");
2821 return false;
2822 }
2823 let local_ref = format!("refs/heads/{branch}");
2824 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2825 Ok(value) => value.trim().to_string(),
2826 Err(error) => {
2827 logdim!(
2828 "kept {branch} because its tip could not be checked: {}",
2829 error.last_line()
2830 );
2831 return false;
2832 }
2833 };
2834 if expected != disposable_head {
2835 logdim!("kept {branch} because it moved beyond the disposable slice");
2836 return false;
2837 }
2838 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2839 Ok(true) => {}
2840 Ok(false) => {
2841 logdim!(
2842 "kept {branch} because its reflog contains work outside the disposable slice"
2843 );
2844 return false;
2845 }
2846 Err(error) => {
2847 logdim!(
2848 "kept {branch} because its reflog could not be checked: {}",
2849 error.last_line()
2850 );
2851 return false;
2852 }
2853 }
2854 match self.head_oid_checked(dir) {
2855 Ok(head) if head == disposable_head => {}
2856 Ok(_) => {
2857 logdim!(
2858 "kept {branch} and {} because the worktree moved beyond the disposable slice",
2859 dir.display()
2860 );
2861 return false;
2862 }
2863 Err(error) => {
2864 logdim!(
2865 "kept {branch} and {} because its head could not be checked: {}",
2866 dir.display(),
2867 error.last_line()
2868 );
2869 return false;
2870 }
2871 }
2872 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2873 Ok(true) => {}
2874 Ok(false) => return false,
2875 Err(error) => {
2876 logdim!(
2877 "kept {branch} and {} because the disposable slice could not be verified: {}",
2878 dir.display(),
2879 error.last_line()
2880 );
2881 return false;
2882 }
2883 }
2884 if let Err(error) =
2885 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2886 {
2887 logdim!(
2888 "kept {branch} because its exact disposable tip could not be deleted: {}",
2889 error.last_line()
2890 );
2891 return false;
2892 }
2893 match self.exact_ref_exists_checked(&self.root, &local_ref) {
2894 Ok(false) => {
2895 self.forget_branch(branch);
2896 true
2897 }
2898 Ok(true) => {
2899 logdim!("kept {branch} because its ref still exists after deletion");
2900 false
2901 }
2902 Err(error) => {
2903 logdim!(
2904 "kept the branch record for {branch} because deletion could not be verified: {}",
2905 error.last_line()
2906 );
2907 false
2908 }
2909 }
2910 }
2911
2912 pub fn release_review_worktree(&self, number: i64) {
2913 let path = self.worktree_path(&format!("review-{number}"));
2914 match self.review_ref_deletion_is_safe(number) {
2915 Ok(true) => {}
2916 Ok(false) => {
2917 logdim!(
2918 "kept {} because no surviving ref preserves its review history",
2919 path.display()
2920 );
2921 return;
2922 }
2923 Err(error) => {
2924 logdim!(
2925 "kept {} because review history could not be verified: {}",
2926 path.display(),
2927 error.last_line()
2928 );
2929 return;
2930 }
2931 }
2932 match self.remove_worktree_at(&path) {
2933 Ok(true) => match self.delete_review_ref_if_safe(number) {
2934 Ok(true) => {}
2935 Ok(false) => logdim!(
2936 "kept {} because its review history changed before deletion",
2937 review_ref(number)
2938 ),
2939 Err(error) => logdim!(
2940 "kept {} because deletion safety could not be rechecked: {}",
2941 review_ref(number),
2942 error.last_line()
2943 ),
2944 },
2945 Ok(false) => {}
2946 Err(error) => logdim!(
2947 "kept {} because removal did not reach a confirmed quiet point: {}",
2948 path.display(),
2949 error.last_line()
2950 ),
2951 }
2952 }
2953
2954 pub(crate) fn release_review_worktree_checked(
2957 &self,
2958 number: i64,
2959 checkpoint: &WorktreeCheckpoint,
2960 ) -> Result<()> {
2961 let path = self.worktree_path(&format!("review-{number}"));
2962 self.require_unchanged_worktree(
2963 &path,
2964 checkpoint,
2965 &format!("review worktree for PR #{number}"),
2966 )?;
2967 if !self.review_ref_deletion_is_safe(number)? {
2968 bail!(
2969 "the review reference for PR #{number} has reflog-only recovery history. The \
2970 worktree and reference were kept."
2971 );
2972 }
2973 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2974 bail!(
2975 "the verified review worktree at {} could not be removed, so its reference was \
2976 kept",
2977 path.display()
2978 );
2979 }
2980 if !self.delete_review_ref_if_safe(number)? {
2981 bail!(
2982 "the review reference for PR #{number} changed before deletion. The reference was \
2983 kept."
2984 );
2985 }
2986 Ok(())
2987 }
2988
2989 pub fn release_pr_worktree(&self, number: i64) -> bool {
2990 let path = self.worktree_path(&format!("pr-{number}"));
2991 let local = self.branch_for_pr(number);
2992 match self.branch_deletion_is_safe(&local) {
2993 Ok(true) => {}
2994 Ok(false) => {
2995 logdim!(
2996 "kept {local} and {} because no surviving ref preserves its tip",
2997 path.display()
2998 );
2999 return false;
3000 }
3001 Err(error) => {
3002 logdim!(
3003 "kept {local} and {} because preservation could not be verified: {}",
3004 path.display(),
3005 error.last_line()
3006 );
3007 return false;
3008 }
3009 }
3010 match self.remove_worktree_at(&path) {
3011 Ok(true) => match self.delete_branch_if_safe(&local) {
3012 Ok(true) => {
3013 self.forget_branch(&local);
3014 true
3015 }
3016 Ok(false) => {
3017 logdim!("kept {local} because its tip or reflog changed before deletion");
3018 false
3019 }
3020 Err(error) => {
3021 logdim!(
3022 "kept {local} because deletion safety could not be rechecked: {}",
3023 error.last_line()
3024 );
3025 false
3026 }
3027 },
3028 Ok(false) => false,
3029 Err(error) => {
3030 logdim!(
3031 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3032 path.display(),
3033 error.last_line()
3034 );
3035 false
3036 }
3037 }
3038 }
3039
3040 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3051 let remote = format!("origin/{base}");
3052 if self.rev_exists(cwd, &remote) {
3053 return remote;
3054 }
3055 if self.rev_exists(cwd, base) {
3056 logdim!("origin/{base} does not resolve, comparing against local {base}");
3057 return base.to_string();
3058 }
3059 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3060 remote
3061 }
3062
3063 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3064 let spec = format!("{refname}^{{commit}}");
3065 !self
3066 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3067 .trim()
3068 .is_empty()
3069 }
3070
3071 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3072 let range = format!("{}..HEAD", self.base_ref(cwd, base));
3073 !self
3074 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3075 .trim()
3076 .is_empty()
3077 }
3078
3079 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3080 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3081 Ok(found.lines().any(|line| line.trim() == refname))
3082 }
3083
3084 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3085 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3086 count.trim().parse::<usize>().map_err(|e| {
3087 spar_err!(
3088 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3089 count.trim()
3090 )
3091 })
3092 }
3093
3094 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3095 let remote = format!("refs/remotes/origin/{base}");
3096 if self.exact_ref_exists_checked(cwd, &remote)? {
3097 return Ok(remote);
3098 }
3099 let local = format!("refs/heads/{base}");
3100 if self.exact_ref_exists_checked(cwd, &local)? {
3101 return Ok(local);
3102 }
3103 bail!("neither origin/{base} nor local branch {base} resolves")
3104 }
3105
3106 pub(crate) fn commit_count_checked(
3107 &self,
3108 cwd: &Path,
3109 refname: &str,
3110 base: &str,
3111 ) -> Result<usize> {
3112 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3113 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3114 count.trim().parse::<usize>().map_err(|e| {
3115 spar_err!(
3116 "git returned an invalid commit count for {range}: {:?} ({e})",
3117 count.trim()
3118 )
3119 })
3120 }
3121
3122 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3123 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3124 }
3125
3126 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3127 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3128 let head = head.trim().to_string();
3129 if head.is_empty() {
3130 bail!("git returned an empty HEAD for {}", cwd.display());
3131 }
3132 Ok(head)
3133 }
3134
3135 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3140 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3141 self.local_branch_is_preserved(branch.trim())
3142 }
3143
3144 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3147 let known = self.known_branches();
3148 let Some(record) = known.get(branch) else {
3149 return Ok(false);
3150 };
3151 self.branch_is_preserved_checked(branch, record)
3152 }
3153
3154 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3156 has_uncommitted_work(cwd)
3157 }
3158
3159 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3162 repository_has_recoverable_work_with(cwd, true, disposable)
3163 }
3164
3165 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3167 let attributes = attribute_state(cwd)?;
3168 Ok(WorktreeBaseline {
3169 attributes,
3170 ignored_untracked: ignored_untracked_state(cwd)?,
3171 git_state: safe_git_state(cwd)?,
3172 })
3173 }
3174
3175 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3178 let attributes = attribute_state(cwd)?;
3179 Ok(WorktreeCheckpoint {
3180 path: std::fs::canonicalize(cwd)
3181 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3182 attributes,
3183 git_state: safe_git_state(cwd)?,
3184 ignored_untracked: ignored_untracked_state(cwd)?,
3185 })
3186 }
3187
3188 pub(crate) fn require_unchanged_worktree(
3191 &self,
3192 cwd: &Path,
3193 checkpoint: &WorktreeCheckpoint,
3194 label: &str,
3195 ) -> Result<()> {
3196 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3197 crate::error::SparError::uncertain_write(format!(
3198 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3199 cwd.display()
3200 ))
3201 })?;
3202 if resolved != checkpoint.path {
3203 return Err(uncertain_worktree_change(
3204 cwd,
3205 format!(
3206 "the {label} moved from {} to {} during inspection. It was kept.",
3207 checkpoint.path.display(),
3208 resolved.display()
3209 ),
3210 ));
3211 }
3212 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3213 uncertain_worktree_change(
3214 cwd,
3215 format!(
3216 "could not verify attribute files in the {label} at {}: {}. It was kept.",
3217 cwd.display(),
3218 e.last_line()
3219 ),
3220 )
3221 })?;
3222 if attributes != checkpoint.attributes {
3223 return Err(uncertain_worktree_change(
3224 cwd,
3225 format!(
3226 "attribute files in the {label} at {} changed during inspection. It was \
3227 kept for recovery.",
3228 cwd.display()
3229 ),
3230 ));
3231 }
3232 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3233 uncertain_worktree_change(
3234 cwd,
3235 format!(
3236 "could not verify the Git state of the {label} at {}: {}. It was kept.",
3237 cwd.display(),
3238 e.last_line()
3239 ),
3240 )
3241 })?;
3242 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3243 uncertain_worktree_change(
3244 cwd,
3245 format!(
3246 "could not verify untracked files in the {label} at {}: {}. It was kept.",
3247 cwd.display(),
3248 e.last_line()
3249 ),
3250 )
3251 })?;
3252 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3253 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3257 let tracked = checkpoint.git_state.describe_difference(&git_state);
3258 let untracked = (!leavings.condemning.is_empty()).then(|| {
3259 format!(
3260 "untracked file(s) changed: {}",
3261 list_paths(&leavings.condemning)
3262 )
3263 });
3264 let Some(reason) = join_reasons(tracked, untracked) else {
3265 return Ok(());
3266 };
3267 Err(uncertain_worktree_change(
3268 cwd,
3269 format!(
3270 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3271 for recovery.",
3272 cwd.display()
3273 ),
3274 ))
3275 }
3276
3277 pub(crate) fn refuse_new_ignored_files(
3281 &self,
3282 cwd: &Path,
3283 baseline: &WorktreeBaseline,
3284 ) -> Result<()> {
3285 self.allow_ignored_files(cwd, baseline).map(drop)
3286 }
3287
3288 pub(crate) fn refuse_failed_edit_leavings(
3290 &self,
3291 cwd: &Path,
3292 baseline: &WorktreeBaseline,
3293 ) -> Result<()> {
3294 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3295 .map(drop)
3296 }
3297
3298 pub(crate) fn refuse_read_only_leavings(
3300 &self,
3301 cwd: &Path,
3302 baseline: &WorktreeBaseline,
3303 ) -> Result<()> {
3304 self.refuse_changed_attributes(cwd, baseline)?;
3305 let after = ignored_untracked_state(cwd).map_err(|e| {
3306 uncertain_worktree_change(
3307 cwd,
3308 format!(
3309 "could not verify untracked files in {} after a read-only call: {}. The \
3310 worktree was kept for recovery.",
3311 cwd.display(),
3312 e.last_line()
3313 ),
3314 )
3315 })?;
3316 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3317 if leavings.condemning.is_empty() {
3318 return Ok(());
3319 }
3320 Err(uncertain_worktree_change(
3321 cwd,
3322 format!(
3323 "the read-only call left untracked file(s) in {} that the project does not \
3324 ignore: {}. The worktree was kept for recovery.",
3325 cwd.display(),
3326 list_paths(&leavings.condemning)
3327 ),
3328 ))
3329 }
3330
3331 fn allow_ignored_files(
3334 &self,
3335 cwd: &Path,
3336 baseline: &WorktreeBaseline,
3337 ) -> Result<AllowedArtifacts> {
3338 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3339 }
3340
3341 fn check_new_ignored_files(
3342 &self,
3343 cwd: &Path,
3344 baseline: &WorktreeBaseline,
3345 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3346 ) -> Result<AllowedArtifacts> {
3347 self.refuse_changed_attributes(cwd, baseline)?;
3348 let after = ignored_untracked_state(cwd).map_err(|e| {
3349 uncertain_worktree_change(
3350 cwd,
3351 format!(
3352 "could not verify untracked files in {} after editing: {}. The worktree was \
3353 kept for recovery.",
3354 cwd.display(),
3355 e.last_line()
3356 ),
3357 )
3358 })?;
3359 let changed = baseline.ignored_untracked.changed_paths(&after);
3360 if changed.is_empty() {
3361 return Ok(AllowedArtifacts::default());
3362 }
3363 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3364 .into_iter()
3365 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3366 if changed.is_empty() {
3367 return Ok(AllowedArtifacts::split(ignored, &after));
3368 }
3369 let listed = list_paths(&changed);
3370 Err(uncertain_worktree_change(
3371 cwd,
3372 format!(
3373 "the editing call created or changed untracked or ignored file(s) in {} that \
3374 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3375 recovery.",
3376 cwd.display()
3377 ),
3378 ))
3379 }
3380
3381 pub(crate) fn refuse_changed_existing_untracked(
3386 &self,
3387 cwd: &Path,
3388 baseline: &WorktreeBaseline,
3389 ) -> Result<()> {
3390 self.check_changed_existing_untracked(cwd, baseline)
3391 .map(drop)
3392 }
3393
3394 fn allow_changed_ignored_artifacts(
3395 &self,
3396 cwd: &Path,
3397 baseline: &WorktreeBaseline,
3398 ) -> Result<AllowedArtifacts> {
3399 self.check_changed_existing_untracked(cwd, baseline)
3400 }
3401
3402 fn check_changed_existing_untracked(
3403 &self,
3404 cwd: &Path,
3405 baseline: &WorktreeBaseline,
3406 ) -> Result<AllowedArtifacts> {
3407 self.refuse_changed_attributes(cwd, baseline)?;
3408 let after = ignored_untracked_state(cwd).map_err(|e| {
3409 uncertain_worktree_change(
3410 cwd,
3411 format!(
3412 "could not verify existing untracked files in {} after editing: {}. The \
3413 worktree was kept for recovery.",
3414 cwd.display(),
3415 e.last_line()
3416 ),
3417 )
3418 })?;
3419 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3420 if changed.is_empty() {
3421 return Ok(AllowedArtifacts::default());
3422 }
3423 let (ignored, changed): (Vec<_>, Vec<_>) = changed
3424 .into_iter()
3425 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3426 if changed.is_empty() {
3427 return Ok(AllowedArtifacts::split(ignored, &after));
3428 }
3429 let listed = list_paths(&changed);
3430 Err(uncertain_worktree_change(
3431 cwd,
3432 format!(
3433 "the editing call changed or deleted existing untracked file(s) in {}: \
3434 {listed}. The worktree was kept for recovery.",
3435 cwd.display()
3436 ),
3437 ))
3438 }
3439
3440 pub(crate) fn refuse_unrepresented_tracked_changes(
3447 &self,
3448 cwd: &Path,
3449 baseline: &WorktreeBaseline,
3450 ) -> Result<()> {
3451 self.refuse_changed_attributes(cwd, baseline)?;
3452 let after = safe_git_state(cwd).map_err(|e| {
3453 uncertain_worktree_change(
3454 cwd,
3455 format!(
3456 "could not verify tracked files in {} after editing: {}. The worktree was \
3457 kept for recovery.",
3458 cwd.display(),
3459 e.last_line()
3460 ),
3461 )
3462 })?;
3463 let mut changed = Vec::new();
3464 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3465 uncertain_worktree_change(
3466 cwd,
3467 format!(
3468 "could not record untracked files before verifying transformed content in {}: \
3469 {}. The worktree was kept for recovery.",
3470 cwd.display(),
3471 e.last_line()
3472 ),
3473 )
3474 })?;
3475 let mut filter_was_run = false;
3476 let mut filter_problem = None;
3477 let mut repositories: BTreeSet<PathBuf> =
3478 baseline.git_state.repositories.keys().cloned().collect();
3479 repositories.extend(after.repositories.keys().cloned());
3480 'repositories: for repository_path in repositories {
3481 let before_repository = baseline.git_state.repositories.get(&repository_path);
3482 let after_repository = after.repositories.get(&repository_path);
3483 if before_repository.is_none() || after_repository.is_none() {
3484 changed.push(repository_path.clone());
3485 continue;
3486 }
3487 if before_repository.map(|repository| &repository.gitlinks)
3488 != after_repository.map(|repository| &repository.gitlinks)
3489 {
3490 changed.push(repository_path.join("<gitlinks>"));
3491 }
3492 let mut paths = BTreeSet::new();
3493 if let Some(repository) = before_repository {
3494 paths.extend(repository.tracked.keys().cloned());
3495 }
3496 if let Some(repository) = after_repository {
3497 paths.extend(repository.tracked.keys().cloned());
3498 }
3499 for path in paths {
3500 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3501 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3502 let worktree_changed =
3503 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3504 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3505 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3506 if !worktree_changed {
3507 continue;
3508 }
3509 if !index_changed {
3510 changed.push(repository_path.join(&path));
3511 continue;
3512 }
3513 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3514 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3515 let Some(current_entry) = current else {
3516 continue;
3517 };
3518 let Some(current_worktree) = current_worktree else {
3519 continue;
3520 };
3521 let content_changed =
3522 before_worktree.map(|file| file.content) != Some(current_worktree.content);
3523 let mode_changed = before_worktree.map(|file| file.mode.as_str())
3524 != Some(current_worktree.mode.as_str());
3525 let repository = cwd.join(&repository_path);
3526 let represented_content = if content_changed {
3527 filter_was_run = true;
3528 let result =
3529 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
3530 self.refuse_changed_attributes(cwd, baseline)?;
3531 match result {
3532 Ok(expected) => expected == current_worktree.content,
3533 Err(error) => {
3534 filter_problem = Some(format!(
3535 "could not verify transformed content for {:?}: {}",
3536 repository_path.join(&path),
3537 error.last_line()
3538 ));
3539 false
3540 }
3541 }
3542 } else {
3543 true
3544 };
3545 let represented_mode =
3546 !mode_changed || current_worktree.mode == current_entry.index_mode;
3547 if !represented_content || !represented_mode {
3548 changed.push(repository_path.join(&path));
3549 }
3550 if filter_problem.is_some() {
3551 break 'repositories;
3552 }
3553 }
3554 }
3555 if filter_was_run {
3556 self.refuse_changed_attributes(cwd, baseline)?;
3557 let verified = safe_git_state(cwd).map_err(|e| {
3558 uncertain_worktree_change(
3559 cwd,
3560 format!(
3561 "could not recheck tracked files after verifying transformed content in \
3562 {}: {}. The worktree was kept for recovery.",
3563 cwd.display(),
3564 e.last_line()
3565 ),
3566 )
3567 })?;
3568 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3569 uncertain_worktree_change(
3570 cwd,
3571 format!(
3572 "could not recheck untracked files after verifying transformed content \
3573 in {}: {}. The worktree was kept for recovery.",
3574 cwd.display(),
3575 e.last_line()
3576 ),
3577 )
3578 })?;
3579 if verified != after
3580 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3581 {
3582 return Err(uncertain_worktree_change(
3583 cwd,
3584 "a content filter changed the worktree while SPAR verified the managed \
3585 commit. The worktree was kept for recovery.",
3586 ));
3587 }
3588 self.refuse_changed_existing_untracked(cwd, baseline)?;
3589 }
3590 if let Some(problem) = filter_problem {
3591 return Err(uncertain_worktree_change(
3592 cwd,
3593 format!("{problem}. The worktree was kept for recovery."),
3594 ));
3595 }
3596 if changed.is_empty() {
3597 return Ok(());
3598 }
3599 let listed = list_paths(&changed);
3600 Err(uncertain_worktree_change(
3601 cwd,
3602 format!(
3603 "the editing call changed tracked working-file bytes, modes, repositories, or \
3604 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3605 recovery."
3606 ),
3607 ))
3608 }
3609
3610 pub(crate) fn refuse_changed_attributes(
3611 &self,
3612 cwd: &Path,
3613 baseline: &WorktreeBaseline,
3614 ) -> Result<()> {
3615 let after = attribute_state(cwd).map_err(|e| {
3616 uncertain_worktree_change(
3617 cwd,
3618 format!(
3619 "could not verify attribute files in {} after editing: {}. The worktree was \
3620 kept for recovery.",
3621 cwd.display(),
3622 e.last_line()
3623 ),
3624 )
3625 })?;
3626 if after == baseline.attributes {
3627 return Ok(());
3628 }
3629 Err(uncertain_worktree_change(
3630 cwd,
3631 format!(
3632 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3633 refused to run a Git operation that could select a new external filter.",
3634 cwd.display()
3635 ),
3636 ))
3637 }
3638
3639 pub(crate) fn commit_pending_changes(
3644 &self,
3645 cwd: &Path,
3646 baseline: &WorktreeBaseline,
3647 preferred_subject: &str,
3648 fallback_subject: &str,
3649 ) -> Result<bool> {
3650 let mut artifacts = IgnoredArtifacts::default();
3651 self.refuse_changed_attributes(cwd, baseline)?;
3652 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3653 refuse_unsafe_index_flags(cwd)?;
3654 if !self.has_uncommitted_changes(cwd)? {
3655 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3656 artifacts.report(cwd);
3657 return Ok(false);
3658 }
3659 self.stage_managed_changes(cwd, baseline).map_err(|e| {
3660 e.with_message(format!(
3661 "could not stage changes in {}: {}",
3662 cwd.display(),
3663 e.last_line()
3664 ))
3665 })?;
3666 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3669 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3670 if !changed_gitlinks.is_empty() {
3671 let listed = list_paths(&changed_gitlinks);
3672 bail!(
3673 "the editing call added or changed a gitlink at {listed}. It was staged but not \
3674 committed because the referenced repository objects might exist only inside \
3675 this worktree. The worktree was kept for recovery."
3676 );
3677 }
3678 let mut subject = self.clean_title(preferred_subject)?;
3679 if subject.trim().is_empty() {
3680 subject = self.clean_title(fallback_subject)?;
3681 }
3682 self.commit_staged_changes(cwd, &subject).map_err(|e| {
3683 e.with_message(format!(
3684 "could not commit changes in {}: {}. The staged files were kept.",
3685 cwd.display(),
3686 e.last_line()
3687 ))
3688 })?;
3689 if has_tracked_or_staged_work(cwd)? {
3690 bail!(
3691 "the commit in {} left additional uncommitted files. They were kept for \
3692 recovery.",
3693 cwd.display()
3694 );
3695 }
3696 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3697 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3698 artifacts.report(cwd);
3699 Ok(true)
3700 }
3701
3702 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3703 let after = ignored_untracked_state(cwd)?;
3704 self.git_at_without_automation(cwd, &["add", "-u"])?;
3705 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3706 if paths.is_empty() {
3707 return Ok(());
3708 }
3709 let mut input = Vec::new();
3710 for path in paths {
3711 input.extend(os_str_bytes(path.as_os_str())?);
3712 input.push(0);
3713 }
3714 let argv = git_without_automation_argv(&[
3715 "--literal-pathspecs",
3716 "add",
3717 "--pathspec-from-file=-",
3718 "--pathspec-file-nul",
3719 ]);
3720 proc::run_with_input_bytes(
3721 &argv,
3722 &self.git_opts(Some(cwd), true).stop_descendants(true),
3723 &input,
3724 )?;
3725 Ok(())
3726 }
3727
3728 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3731 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3732 .map(|_| ())
3733 }
3734
3735 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3742 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3743 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3744 .trim()
3745 .parse()
3746 .unwrap_or(0)
3747 }
3748
3749 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3753 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3754 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3755 .lines()
3756 .map(str::to_string)
3757 .collect()
3758 }
3759
3760 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3775 let ancestor = self
3776 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3777 .is_ok();
3778 if !ancestor {
3779 return None;
3780 }
3781 let range = format!("{earlier}..{later}");
3782 Some(
3783 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3784 .lines()
3785 .map(str::to_string)
3786 .collect(),
3787 )
3788 }
3789
3790 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3793 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3794 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3795 .lines()
3796 .map(str::trim)
3797 .filter(|line| !line.is_empty())
3798 .map(str::to_string)
3799 .collect()
3800 }
3801
3802 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3818 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3819 self.git_try_at(
3820 Some(cwd),
3821 &["diff", "--name-only", "--no-renames", "-z", &range],
3822 )
3823 .split('\0')
3824 .filter(|path| !path.is_empty())
3825 .map(str::to_string)
3826 .collect()
3827 }
3828
3829 pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
3844 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3845 let argv = git_without_maintenance_argv(&[
3846 "-c",
3847 "core.quotePath=false",
3848 "diff",
3849 "--no-color",
3850 "--no-ext-diff",
3851 "--no-textconv",
3852 "--unified=3",
3853 "--inter-hunk-context=0",
3854 "--diff-algorithm=myers",
3855 "--indent-heuristic",
3856 "--submodule=short",
3857 "--find-renames",
3858 "--src-prefix=a/",
3859 "--dst-prefix=b/",
3860 &range,
3861 ]);
3862 let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
3863 new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
3864 }
3865
3866 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3869 self.git_try_at(
3870 Some(cwd),
3871 &[
3872 "diff",
3873 "--name-only",
3874 "--no-renames",
3875 "-z",
3876 &format!("{from}..{to}"),
3877 ],
3878 )
3879 .split('\0')
3880 .filter(|path| !path.is_empty())
3881 .map(str::to_string)
3882 .collect()
3883 }
3884
3885 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3888 let base_ref = self.base_ref(cwd, base);
3889 let out = self
3890 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3891 .map_err(|e| {
3892 spar_err!(
3893 "could not find where {refname} and {base_ref} diverged. {}",
3894 e.last_line()
3895 )
3896 })?;
3897 let sha = out.trim().to_string();
3898 if sha.is_empty() {
3899 bail!("{refname} and {base_ref} share no history");
3900 }
3901 Ok(sha)
3902 }
3903
3904 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3905 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3906 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3907 full.trim().to_string()
3908 }
3909
3910 pub fn rewrite_commits_if_needed(
3923 &self,
3924 cwd: &Path,
3925 base: &str,
3926 floor: Option<&str>,
3927 ) -> Result<()> {
3928 let base_ref = self.base_ref(cwd, base);
3929 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3930 self.report_commits_left_alone(cwd, &base_ref, floor);
3931
3932 let range = format!("{floor}..HEAD");
3933 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3934
3935 let offenders = raw
3936 .split('\x1e')
3937 .filter_map(|entry| entry.split_once('\0'))
3938 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3939 .count();
3940 if offenders == 0 {
3941 return Ok(());
3942 }
3943 logdim!("{offenders} commit message(s) violated style rules, rewriting");
3944
3945 let exe = self_binary()?;
3946 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3947
3948 let argv: Vec<String> = [
3949 "git",
3950 "filter-branch",
3951 "-f",
3952 "--msg-filter",
3953 &filter,
3954 &range,
3955 ]
3956 .iter()
3957 .map(|s| s.to_string())
3958 .collect();
3959 let opts = ExecOpts::new()
3960 .cwd(cwd)
3961 .check(false)
3962 .timeout_secs(600)
3963 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3964 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3965 .env(
3966 "SPAR_BAN_AI_ATTRIBUTION",
3967 bool_env(self.style.ban_ai_attribution),
3968 );
3969 let _ = proc::run(&argv, &opts);
3970
3971 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3972 if !style::violations(&after, &self.style).is_empty() {
3973 bail!(
3974 "commit messages still violate style rules after a rewrite in {}.",
3975 cwd.display()
3976 );
3977 }
3978 Ok(())
3979 }
3980
3981 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3986 if floor == base_ref {
3987 return;
3988 }
3989 let raw = self.git_try_at(
3990 Some(cwd),
3991 &[
3992 "log",
3993 &format!("{base_ref}..{floor}"),
3994 "--format=%H%x00%B%x1e",
3995 ],
3996 );
3997 let left: Vec<&str> = raw
3998 .split('\x1e')
3999 .filter_map(|entry| entry.split_once('\0'))
4000 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4001 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4002 .collect();
4003 if left.is_empty() {
4004 return;
4005 }
4006 logdim!(
4007 "{} commit message(s) already on the branch break the style rules and were left \
4008 alone: {}. spar does not rewrite commits it did not write.",
4009 left.len(),
4010 left.join(", ")
4011 );
4012 }
4013
4014 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4029 self.push_to(cwd, &PushTarget::Origin, branch, expected)
4030 }
4031
4032 pub fn push_to(
4034 &self,
4035 cwd: &Path,
4036 target: &PushTarget,
4037 branch: &str,
4038 expected: Option<&str>,
4039 ) -> Result<()> {
4040 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4041 if let Some(head) = expected {
4042 self.refuse_push_that_drops(cwd, head)?;
4043 }
4044 let pushed = self
4045 .git_push(cwd, target, branch, expected)
4046 .map_err(|e| push_error(target, branch, &e));
4047 self.record_write(pushed)
4048 }
4049
4050 pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4062 let origin = PushTarget::Origin;
4063 let refused = match self.git_push(cwd, &origin, branch, None) {
4064 Ok(()) => return self.record_write(Ok(origin)),
4065 Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4066 Err(e) => {
4067 let err = push_error(&origin, branch, &e);
4068 let err = if push_denial(&e.to_string()).is_some() {
4069 spar_err!(
4070 "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4071 instead."
4072 )
4073 } else {
4074 err
4075 };
4076 return self.record_write(Err(err));
4077 }
4078 };
4079 self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4080 }
4081
4082 fn publish_on_fork(
4083 &self,
4084 cwd: &Path,
4085 branch: &str,
4086 base: &str,
4087 refused: &crate::error::SparError,
4088 ) -> Result<PushTarget> {
4089 let fork = self.fork().map_err(|e| {
4090 spar_err!(
4091 "{} spar could not publish it from a fork instead: {}",
4092 push_error(&PushTarget::Origin, branch, refused),
4093 e.last_line()
4094 )
4095 })?;
4096 let lease = self.fork_branch_lease(&fork, branch, base)?;
4097 let refused = refused.to_string();
4098 log!(
4099 "{} Publishing {branch} from the fork {} instead.",
4100 push_denial(&refused).unwrap_or("origin refused the push."),
4101 fork.full_name
4102 );
4103 let target = PushTarget::Fork(fork);
4104 self.git_push(cwd, &target, branch, lease.as_deref())
4107 .map_err(|e| push_error(&target, branch, &e))?;
4108 Ok(target)
4109 }
4110
4111 fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4119 let live_ref = format!("refs/heads/{branch}");
4120 let unreadable = |e: crate::error::SparError| {
4121 spar_err!(
4122 "could not check whether the fork {} already has {branch}. {}",
4123 fork.full_name,
4124 e.last_line()
4125 )
4126 };
4127 let listed = self
4128 .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4129 .map_err(unreadable)?;
4130 let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4131 return Ok(None);
4132 };
4133 let local = format!("refs/spar/fork/{branch}");
4134 self.git(&[
4135 "fetch",
4136 "--no-tags",
4137 &fork.url,
4138 &format!("+{live_ref}:{local}"),
4139 ])
4140 .map_err(unreadable)?;
4141 let ahead = self.commit_count_checked(&self.root, &local, base);
4142 let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4143 self.git_try(&["update-ref", "-d", &local]);
4144 let ahead = ahead?;
4145 if !held {
4146 bail!(
4147 "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4148 and no pull request accounts for them. Publishing over it would lose that work.\n\
4149 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4150 run spar again.",
4151 fork.full_name,
4152 fork.url
4153 );
4154 }
4155 Ok(Some(tip))
4156 }
4157
4158 fn git_push(
4159 &self,
4160 cwd: &Path,
4161 target: &PushTarget,
4162 branch: &str,
4163 expected: Option<&str>,
4164 ) -> Result<()> {
4165 let refspec = format!("HEAD:refs/heads/{branch}");
4166 let lease = match (expected, target) {
4167 (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4168 (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4172 (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4173 };
4174 self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4175 .map(|_| ())
4176 }
4177
4178 fn fork(&self) -> Result<Fork> {
4185 match self.existing_fork() {
4186 Ok(Some(fork)) => return Ok(fork),
4187 Ok(None) => {}
4188 Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4189 }
4190 let answer = self
4191 .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4192 .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4193 let fork = fork_from_api(&answer, &self.origin_push_url())?;
4194 self.wait_for_fork(&fork)?;
4195 Ok(self.fork.get_or_init(|| fork).clone())
4196 }
4197
4198 fn existing_fork(&self) -> Result<Option<Fork>> {
4201 if let Some(fork) = self.fork.get() {
4202 return Ok(Some(fork.clone()));
4203 }
4204 let answer = self
4205 .gh(&[
4206 "api",
4207 "graphql",
4208 "-F",
4209 "owner={owner}",
4210 "-F",
4211 "name={repo}",
4212 "-f",
4213 "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4214 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4215 login } sshUrl url } } } }",
4216 "--jq",
4217 ".data.repository.forks.nodes",
4218 ])
4219 .map_err(|e| {
4220 spar_err!(
4221 "could not look for a fork of this repository. {}",
4222 e.last_line()
4223 )
4224 })?;
4225 let found = fork_from_listing(&answer, &self.origin_push_url())?;
4226 Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4227 }
4228
4229 fn origin_refuses_push(&self) -> bool {
4233 self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4234 .trim()
4235 == "false"
4236 }
4237
4238 fn origin_push_url(&self) -> String {
4239 self.git_try(&["remote", "get-url", "--push", "origin"])
4240 }
4241
4242 pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4247 if !pr.is_cross_repository {
4248 return Some(PushTarget::Origin);
4249 }
4250 let viewer = self.viewer_login().ok()?;
4251 own_fork_target(pr, viewer, &self.origin_push_url())
4252 }
4253
4254 fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4255 const TRIES: u32 = 30;
4256 let mut tries = 1;
4257 loop {
4258 match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4259 Ok(_) => return Ok(()),
4260 Err(e) if tries >= TRIES => bail!(
4261 "the fork {} could not be reached after {} seconds. {}",
4262 fork.full_name,
4263 TRIES * 2,
4264 e.last_line()
4265 ),
4266 Err(_) => {
4267 tries += 1;
4268 std::thread::sleep(std::time::Duration::from_secs(2));
4269 }
4270 }
4271 }
4272 }
4273
4274 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4282 let head = self.head_oid_checked(cwd)?;
4283 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4284 return Ok(());
4285 }
4286 bail!(
4287 "refusing to push {head} because it does not contain the reviewed head {expected}. \
4288 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4289 the intended commits on top of it before resuming.",
4290 cwd.display()
4291 )
4292 }
4293
4294 pub fn push_split_branch(
4303 &self,
4304 cwd: &Path,
4305 branch: &str,
4306 ) -> std::result::Result<(), SplitPushError> {
4307 let remote_ref = format!("refs/heads/{branch}");
4308 let lease = format!("--force-with-lease={remote_ref}:");
4309 let refspec = format!("HEAD:{remote_ref}");
4310 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4311 Ok(_) => Ok(()),
4312 Err(push_error) => {
4313 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4314 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4315 reconcile_failed_split_push(branch, push_error, local, remote)
4316 }
4317 };
4318 self.record_write(result)
4319 }
4320
4321 pub fn gh(&self, args: &[&str]) -> Result<String> {
4324 self.gh_at(None, args)
4325 }
4326
4327 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4328 let mut argv = vec!["gh".to_string()];
4329 argv.extend(args.iter().map(|s| s.to_string()));
4330 proc::run(
4331 &argv,
4332 &ExecOpts::new()
4333 .cwd(cwd.unwrap_or(&self.root))
4334 .timeout_secs(300),
4335 )
4336 }
4337
4338 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4344 let mut argv = vec!["gh".to_string()];
4345 argv.extend(args.iter().map(|s| s.to_string()));
4346 proc::run(
4347 &argv,
4348 &ExecOpts::new()
4349 .cwd(&self.root)
4350 .timeout_secs(300)
4351 .stdin(stdin),
4352 )
4353 }
4354
4355 pub fn gh_try(&self, args: &[&str]) -> String {
4356 let mut argv = vec!["gh".to_string()];
4357 argv.extend(args.iter().map(|s| s.to_string()));
4358 proc::run(
4359 &argv,
4360 &ExecOpts::new()
4361 .cwd(&self.root)
4362 .check(false)
4363 .timeout_secs(300),
4364 )
4365 .unwrap_or_default()
4366 }
4367
4368 pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4373 let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4374 for (name, value) in headers {
4375 argv.push("-H".to_string());
4376 argv.push(format!("{name}: {value}"));
4377 }
4378 argv.push(endpoint.to_string());
4379 let out = proc::exec(
4380 &argv,
4381 &ExecOpts::new()
4382 .cwd(&self.root)
4383 .check(false)
4384 .timeout_secs(300),
4385 )?;
4386 parse_api_response(&out.stdout)
4387 .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4388 }
4389
4390 pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4392 let text = self.gh(&[
4393 "pr",
4394 "list",
4395 "--state",
4396 "open",
4397 "--limit",
4398 "500",
4399 "--json",
4400 PR_SWEEP_FIELDS,
4401 ])?;
4402 serde_json::from_str(&text)
4403 .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4404 }
4405
4406 pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4408 let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4409 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4410 }
4411
4412 pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4417 let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4419 if slug.is_empty() || login.is_empty() {
4420 return None;
4421 }
4422 let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4423 let resp = self.gh_api_raw(&path, &[]).ok()?;
4424 match resp.status {
4425 200 => {
4426 let body: Value = serde_json::from_str(&resp.body).ok()?;
4427 Some(body.get("state")?.as_str()? == "active")
4428 }
4429 404 => Some(false),
4430 _ => None,
4431 }
4432 }
4433
4434 pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4441 let text = self
4442 .gh(&[
4443 "api",
4444 "graphql",
4445 "-F",
4446 "owner={owner}",
4447 "-F",
4448 "repo={repo}",
4449 "-F",
4450 &format!("number={number}"),
4451 "-f",
4452 &format!("query={REQUESTS_QUERY}"),
4453 ])
4454 .ok()?;
4455 parse_review_requests(&text)
4456 }
4457
4458 pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4460 if from.is_empty() || to.is_empty() {
4461 return None;
4462 }
4463 let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4464 self.gh_try(&["api", &path, "--jq", ".total_commits"])
4465 .trim()
4466 .parse()
4467 .ok()
4468 }
4469
4470 pub fn repo_name(&self) -> Option<String> {
4472 let name = self.gh_try(&[
4473 "repo",
4474 "view",
4475 "--json",
4476 "nameWithOwner",
4477 "--jq",
4478 ".nameWithOwner",
4479 ]);
4480 let name = name.trim();
4481 (!name.is_empty()).then(|| name.to_string())
4482 }
4483
4484 pub fn listen_state_path(&self) -> PathBuf {
4485 self.root.join(STATE_DIR).join("state").join("listen.json")
4486 }
4487
4488 pub fn listen_lock_path(&self) -> PathBuf {
4489 self.root.join(STATE_DIR).join("state").join("listen.lock")
4490 }
4491
4492 pub fn viewer_login(&self) -> Result<&str> {
4503 if let Some(login) = self.viewer.get() {
4504 return Ok(login);
4505 }
4506 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4507 let login = if !rest.trim().is_empty() {
4508 rest.trim().to_string()
4509 } else {
4510 self.gh(&[
4513 "api",
4514 "graphql",
4515 "-f",
4516 "query={ viewer { login } }",
4517 "--jq",
4518 ".data.viewer.login",
4519 ])
4520 .map_err(|e| {
4521 spar_err!(
4522 "could not find out who `gh` is authenticated as, so spar cannot tell its \
4523 own comments from anybody else's. {}\nRun `gh auth status`.",
4524 e.last_line()
4525 )
4526 })?
4527 .trim()
4528 .to_string()
4529 };
4530 if login.is_empty() {
4531 bail!("`gh` reported an empty login. Run `gh auth status`.");
4532 }
4533 Ok(self.viewer.get_or_init(|| login))
4534 }
4535
4536 pub fn read_issue(&self, number: i64) -> Result<Issue> {
4542 let text = self
4543 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4544 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4545 serde_json::from_str(&text)
4546 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4547 }
4548
4549 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4550 let mut issues = Vec::new();
4551 for number in numbers {
4552 let text = self
4553 .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4554 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4555 let issue: Issue = serde_json::from_str(&text)
4556 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4557 if issue.is_closed() {
4558 crate::log!("issue #{number} is closed, skipping");
4559 continue;
4560 }
4561 issues.push(issue);
4562 }
4563 if issues.is_empty() {
4564 bail!("no open issues to work on");
4565 }
4566 Ok(issues)
4567 }
4568
4569 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4575 #[derive(Deserialize)]
4576 struct Row {
4577 number: i64,
4578 }
4579 let text = self.gh(&[
4580 kind,
4581 "list",
4582 "--state",
4583 "open",
4584 "--limit",
4585 &FETCH_CEILING.to_string(),
4586 "--json",
4587 "number",
4588 ])?;
4589 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4590 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4591 numbers.sort_unstable();
4592
4593 let noun = if kind == "issue" { "issues" } else { "PRs" };
4594 let found = numbers.len();
4595 if min_number > 0 {
4596 numbers.retain(|n| *n >= min_number);
4597 let skipped = found - numbers.len();
4598 if skipped > 0 {
4599 crate::log!("{skipped} open {noun} below #{min_number} skipped");
4600 }
4601 }
4602 if found >= FETCH_CEILING {
4603 crate::log!(
4604 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4605 considered."
4606 );
4607 }
4608 if numbers.len() > limit {
4609 crate::log!(
4610 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4611 explicitly.",
4612 numbers.len()
4613 );
4614 numbers.truncate(limit);
4615 }
4616 Ok(numbers)
4617 }
4618
4619 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4621 self.open_numbers("issue", limit, min_number)
4622 }
4623
4624 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4625 self.open_numbers("pr", limit, min_number)
4626 }
4627
4628 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4629 self.branch_prs(branch, "open").into_iter().next()
4630 }
4631
4632 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4635 let text = self.gh(&[
4636 "pr",
4637 "list",
4638 "--head",
4639 branch,
4640 "--base",
4641 base,
4642 "--state",
4643 "open",
4644 "--json",
4645 "number,url,title,baseRefName",
4646 ])?;
4647 pr_for_base(&text, branch, base)
4648 }
4649
4650 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4654 self.branch_prs(branch, "all")
4655 }
4656
4657 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4658 let text = self.gh_try(&[
4659 "pr",
4660 "list",
4661 "--head",
4662 branch,
4663 "--state",
4664 state,
4665 "--json",
4666 "number,url,title",
4667 ]);
4668 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4669 }
4670
4671 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4677 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4678 let text = self
4679 .gh(&[
4680 "api",
4681 &path,
4682 "--jq",
4683 "if .pull_request then \"pr\" else \"issue\" end",
4684 ])
4685 .map_err(|e| {
4686 spar_err!(
4687 "no issue or pull request #{number} in this repository. {}",
4688 e.last_line()
4689 )
4690 })?;
4691 match text.trim() {
4692 "pr" => Ok(ItemKind::Pr),
4693 "issue" => Ok(ItemKind::Issue),
4694 other => Err(spar_err!(
4695 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4696 )),
4697 }
4698 }
4699
4700 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4711 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4712 if let Some(pr) = mine {
4713 return Ok(Some(pr));
4714 }
4715 let listed = match self.gh(&[
4716 "pr",
4717 "list",
4718 "--state",
4719 "open",
4720 "--limit",
4721 &FETCH_CEILING.to_string(),
4722 "--json",
4723 "number,url,title,baseRefName,closingIssuesReferences",
4724 ]) {
4725 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4726 spar_err!("could not read the pull request list while looking for #{issue}")
4727 })?,
4728 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4729 };
4730 if listed.len() >= FETCH_CEILING {
4731 logwarn!(
4732 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
4733 );
4734 }
4735 let default = self.default_branch(base);
4736 if !base.eq_ignore_ascii_case(&default) {
4737 logwarn!(
4741 "#{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."
4742 );
4743 }
4744 Ok(pick_linked_pr(&listed, issue))
4745 }
4746
4747 fn pr_for_branch_failing_closed(
4749 &self,
4750 branch: &str,
4751 base: &str,
4752 issue: i64,
4753 ) -> Result<Option<PrRef>> {
4754 match self.try_pr_for_branch(branch, base) {
4755 Ok(found) => Ok(found),
4756 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4757 }
4758 }
4759
4760 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4769 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4770 return Err(spar_err!(
4771 "could not check whether a pull request already exists for #{issue}: {}. \
4772 Refusing to implement over the top of work that may already be open.",
4773 e.last_line()
4774 ));
4775 }
4776 logwarn!(
4777 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4778 whether a pull request already exists for it.",
4779 e.last_line()
4780 );
4781 Ok(())
4782 }
4783
4784 pub fn pr_view(&self, number: i64) -> Result<PrView> {
4785 let text = self.gh(&[
4786 "pr",
4787 "view",
4788 &number.to_string(),
4789 "--json",
4790 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
4791 isCrossRepository,headRepositoryOwner,headRepository",
4792 ])?;
4793 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4794 }
4795
4796 fn try_pr_state(&self, number: i64) -> Result<String> {
4797 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4798 serde_json::from_str::<Value>(text.trim())
4799 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4800 .get("state")
4801 .and_then(Value::as_str)
4802 .map(str::to_string)
4803 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4804 }
4805
4806 pub fn pr_state(&self, number: i64) -> String {
4807 self.try_pr_state(number).unwrap_or_default()
4808 }
4809
4810 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
4812 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
4813 let oid = serde_json::from_str::<Value>(&text)
4814 .ok()
4815 .and_then(|value| {
4816 value
4817 .get("headRefOid")
4818 .and_then(Value::as_str)
4819 .map(str::trim)
4820 .filter(|oid| !oid.is_empty())
4821 .map(str::to_string)
4822 })
4823 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
4824 Ok(oid)
4825 }
4826
4827 pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
4831 let pull = self.pr_head_oid(pr.number)?;
4832 let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
4833 None
4834 } else {
4835 let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
4836 let listed = self
4837 .git(&["ls-remote", "--heads", "origin", &remote_ref])
4838 .map_err(|e| {
4839 spar_err!(
4840 "could not read where {} is on origin: {}",
4841 pr.head_ref_name.trim(),
4842 e.last_line()
4843 )
4844 })?;
4845 remote_head_oid(&listed, &remote_ref)?
4846 };
4847 Ok(LiveHead { pull, branch })
4848 }
4849
4850 pub fn create_pr(
4855 &self,
4856 cwd: &Path,
4857 branch: &str,
4858 target: &PushTarget,
4859 base: &str,
4860 title: &str,
4861 body: &str,
4862 ) -> Result<PrRef> {
4863 let title = self.record_failed_write(self.clean_title(title))?;
4864 let body = self.record_failed_write(self.clean(body))?;
4865 let head = target.head(branch);
4866 let mut argv = vec![
4867 "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
4868 ];
4869 if self.drafts != Drafts::Never {
4870 argv.push("--draft");
4871 }
4872 let created = self.gh_at(Some(cwd), &argv);
4873 let found = self.try_pr_for_branch(branch, base);
4874 self.record_write(reconcile_pr_creation(branch, created, found))
4875 }
4876
4877 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
4878 let body = signed(&self.record_failed_write(self.clean(body))?);
4879 let comments = self.record_failed_write(self.try_issue_comments(number))?;
4880 if has_exact_comment(&comments, &body) {
4881 return Ok(());
4882 }
4883 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
4884 let result = match posted {
4885 Ok(_) => Ok(()),
4886 Err(post_error) => {
4887 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4888 }
4889 };
4890 self.record_write(result)
4891 }
4892
4893 fn pr_is_own(&self, number: i64) -> Result<bool> {
4897 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
4898 let author = serde_json::from_str::<Value>(&text)
4899 .ok()
4900 .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
4901 .unwrap_or_default();
4902 let viewer = self.viewer_login()?.to_string();
4903 Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
4904 }
4905
4906 pub fn submit_review(
4927 &self,
4928 number: i64,
4929 verdict: Option<crate::model::ReviewVerdict>,
4930 body: &str,
4931 commit: &str,
4932 comments: &[InlineComment],
4933 ) -> Result<()> {
4934 let own = match verdict {
4935 Some(_) => self.record_failed_write(self.pr_is_own(number))?,
4936 None => false,
4937 };
4938 let text = match (verdict, own) {
4939 (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
4940 _ => body.to_string(),
4941 };
4942 let body = signed(&self.record_failed_write(self.clean(&text))?);
4943 let mut inline = Vec::with_capacity(comments.len());
4944 for c in comments {
4945 inline.push(InlineComment {
4946 body: signed(&self.record_failed_write(self.clean(&c.body))?),
4947 ..c.clone()
4948 });
4949 }
4950 let viewer = self.record_failed_write(self.viewer_login())?.to_string();
4951 let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
4952 if has_viewer_review(&reviews, &viewer, &body, commit) {
4953 return Ok(());
4954 }
4955 let event = match (verdict, own) {
4956 (Some(v), false) => v.api_event(),
4957 _ => "COMMENT",
4958 };
4959 let result = if inline.is_empty() {
4960 self.post_review(number, &body, &viewer, commit, event)
4961 } else {
4962 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
4963 let payload = review_payload(commit, event, &body, &inline).to_string();
4964 match self.gh_stdin(
4965 &["api", "-X", "POST", &path, "--input", "-", "--silent"],
4966 &payload,
4967 ) {
4968 Ok(_) => Ok(()),
4969 Err(post_error) if !refused(&post_error) => reconcile_review_post(
4970 number,
4971 &body,
4972 &viewer,
4973 commit,
4974 post_error,
4975 self.try_pr_reviews(number),
4976 ),
4977 Err(post_error) => match self.try_pr_reviews(number) {
4978 Ok(reviews) if !has_viewer_review(&reviews, &viewer, &body, commit) => {
4979 logwarn!(
4980 "the review of #{number} with its line comments did not go up ({}), \
4981 so it is going up with every finding in its body only",
4982 post_error.last_line()
4983 );
4984 self.post_review(number, &body, &viewer, commit, event)
4985 }
4986 reviews => {
4987 reconcile_review_post(number, &body, &viewer, commit, post_error, reviews)
4988 }
4989 },
4990 }
4991 };
4992 self.record_write(result)
4993 }
4994
4995 fn post_review(
4997 &self,
4998 number: i64,
4999 body: &str,
5000 viewer: &str,
5001 commit: &str,
5002 event: &str,
5003 ) -> Result<()> {
5004 let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5005 let posted = self.gh(&[
5006 "api",
5007 "-X",
5008 "POST",
5009 &path,
5010 "-f",
5011 &format!("commit_id={commit}"),
5012 "-f",
5013 &format!("event={event}"),
5014 "-f",
5015 &format!("body={body}"),
5016 "--silent",
5017 ]);
5018 match posted {
5019 Ok(_) => Ok(()),
5020 Err(post_error) => reconcile_review_post(
5021 number,
5022 body,
5023 viewer,
5024 commit,
5025 post_error,
5026 self.try_pr_reviews(number),
5027 ),
5028 }
5029 }
5030
5031 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5032 let body = signed(&self.record_failed_write(self.clean(body))?);
5033 let comments = self.record_failed_write(self.try_issue_comments(number))?;
5034 if has_exact_comment(&comments, &body) {
5035 return Ok(());
5036 }
5037 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5038 let result = match posted {
5039 Ok(_) => Ok(()),
5040 Err(post_error) => {
5041 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5042 }
5043 };
5044 self.record_write(result)
5045 }
5046
5047 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5052 self.comment_issue(number, body)?;
5053 let n = number.to_string();
5054 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5055 Ok(_) => Ok(()),
5056 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5058 spar_err!(
5059 "commented on #{number} but could not close it: {}",
5060 e.last_line()
5061 )
5062 }),
5063 };
5064 self.record_write(closed)
5065 }
5066
5067 pub fn edit_issue_body(
5082 &self,
5083 number: i64,
5084 expected: &str,
5085 body: &str,
5086 inserted: &str,
5087 ) -> Result<()> {
5088 let cleaned = self.record_failed_write(self.clean(inserted))?;
5089 if cleaned.trim() != inserted.trim() {
5090 return self.record_failed_write(Err(spar_err!(
5091 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5092 )));
5093 }
5094 let current = self.record_failed_write(self.issue_body(number))?;
5095 if current != expected {
5096 return self.record_failed_write(Err(spar_err!(
5097 "the body of #{number} changed since it was read, so it was left alone rather \
5098 than written over."
5099 )));
5100 }
5101 let edited = self.gh_stdin(
5102 &["issue", "edit", &number.to_string(), "--body-file", "-"],
5103 body,
5104 );
5105 let result = match edited {
5106 Ok(_) => Ok(()),
5107 Err(edit_error) => {
5108 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5109 }
5110 };
5111 self.record_write(result)
5112 }
5113
5114 pub fn issue_body(&self, number: i64) -> Result<String> {
5116 #[derive(Deserialize)]
5117 struct Row {
5118 #[serde(default)]
5119 body: Option<String>,
5120 }
5121 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5122 let row: Row = serde_json::from_str(text.trim())
5123 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5124 Ok(row.body.unwrap_or_default())
5125 }
5126
5127 pub fn open_issue_rows(&self) -> Vec<Issue> {
5132 let text = self.gh_try(&[
5133 "issue",
5134 "list",
5135 "--state",
5136 "open",
5137 "--limit",
5138 &FETCH_CEILING.to_string(),
5139 "--json",
5140 "number,title,body,labels,state,stateReason,url",
5141 ]);
5142 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5143 }
5144
5145 pub fn open_pr_rows(&self) -> Vec<PrRow> {
5147 let text = self.gh_try(&[
5148 "pr",
5149 "list",
5150 "--state",
5151 "open",
5152 "--limit",
5153 &FETCH_CEILING.to_string(),
5154 "--json",
5155 "number,title,changedFiles,additions,deletions",
5156 ]);
5157 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5158 }
5159
5160 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5161 self.create_issue_apart_from(title, body, None)
5162 }
5163
5164 pub fn create_issue_apart_from(
5165 &self,
5166 title: &str,
5167 body: &str,
5168 apart_from: Option<i64>,
5169 ) -> Result<String> {
5170 let title = self.record_failed_write(self.clean_title(title))?;
5171 let body = self.record_failed_write(self.clean_issue_body(body))?;
5172 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5173 let result = match created {
5174 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5175 created => {
5176 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5177 reconcile_issue_creation(&title, created, found)
5178 }
5179 };
5180 self.record_write(result)
5181 }
5182}
5183
5184#[derive(Debug, Clone)]
5186pub struct ExistingIssue {
5187 pub number: i64,
5188 pub url: String,
5189 pub title: String,
5190 pub body: String,
5191 pub open: bool,
5192}
5193
5194impl Repo {
5195 pub(crate) fn try_exact_issue_apart_from(
5196 &self,
5197 title: &str,
5198 body: &str,
5199 apart_from: Option<i64>,
5200 ) -> Result<Option<ExistingIssue>> {
5201 #[derive(Deserialize)]
5202 #[serde(rename_all = "camelCase")]
5203 struct Row {
5204 number: i64,
5205 #[serde(default)]
5206 title: String,
5207 #[serde(default)]
5208 url: String,
5209 #[serde(default)]
5210 body: Option<String>,
5211 #[serde(default)]
5212 state: String,
5213 }
5214
5215 let text = self.gh(&[
5216 "issue",
5217 "list",
5218 "--state",
5219 "all",
5220 "--limit",
5221 "100",
5222 "--json",
5223 "number,title,url,body,state,stateReason",
5224 ])?;
5225 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5226 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5227 Ok(rows
5228 .into_iter()
5229 .filter(|row| Some(row.number) != apart_from)
5230 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5231 .map(|row| ExistingIssue {
5232 number: row.number,
5233 url: row.url,
5234 title: row.title,
5235 body: row.body.unwrap_or_default(),
5236 open: row.state.eq_ignore_ascii_case("open"),
5237 }))
5238 }
5239
5240 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5248 self.find_similar_issue_apart_from(title, body, None)
5249 }
5250
5251 pub fn find_similar_issue_apart_from(
5259 &self,
5260 title: &str,
5261 body: &str,
5262 apart_from: Option<i64>,
5263 ) -> Option<ExistingIssue> {
5264 self.try_similar_issue(title, body, apart_from, true)
5265 .ok()
5266 .flatten()
5267 }
5268
5269 pub fn try_find_similar_issue_apart_from(
5271 &self,
5272 title: &str,
5273 body: &str,
5274 apart_from: Option<i64>,
5275 ) -> Result<Option<ExistingIssue>> {
5276 self.try_similar_issue(title, body, apart_from, false)
5277 }
5278
5279 fn try_similar_issue(
5281 &self,
5282 title: &str,
5283 body: &str,
5284 apart_from: Option<i64>,
5285 include_closed: bool,
5286 ) -> Result<Option<ExistingIssue>> {
5287 #[derive(Deserialize)]
5288 #[serde(rename_all = "camelCase")]
5289 struct Row {
5290 number: i64,
5291 #[serde(default)]
5292 title: String,
5293 #[serde(default)]
5294 url: String,
5295 #[serde(default)]
5296 body: String,
5297 #[serde(default)]
5298 state: String,
5299 }
5300 if title.trim().is_empty() {
5301 return Ok(None);
5302 }
5303 let query: String = title
5306 .chars()
5307 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5308 .take(120)
5309 .collect();
5310 let state = if include_closed { "all" } else { "open" };
5319 let text = self.gh(&[
5320 "issue",
5321 "list",
5322 "--state",
5323 state,
5324 "--limit",
5325 "100",
5326 "--search",
5327 query.trim(),
5328 "--json",
5329 "number,title,url,body,state,stateReason",
5330 ])?;
5331 let rows: Vec<Row> = serde_json::from_str(text.trim())
5332 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5333
5334 Ok(rows
5335 .into_iter()
5336 .filter(|row| Some(row.number) != apart_from)
5337 .find(|row| {
5338 row.title.trim().eq_ignore_ascii_case(title.trim())
5339 || textsim::covers(title, body, &row.title, &row.body)
5340 })
5341 .map(|row| ExistingIssue {
5342 number: row.number,
5343 url: row.url,
5344 title: row.title,
5345 open: row.state.eq_ignore_ascii_case("open"),
5346 body: row.body,
5347 }))
5348 }
5349
5350 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5352 #[derive(Deserialize)]
5353 struct Row {
5354 title: String,
5355 url: String,
5356 }
5357 let needle = title.trim().to_lowercase();
5358 if needle.is_empty() {
5359 return None;
5360 }
5361 let query: String = title
5363 .chars()
5364 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5365 .take(120)
5366 .collect();
5367 let text = self.gh_try(&[
5368 "issue",
5369 "list",
5370 "--state",
5371 "all",
5372 "--limit",
5373 "100",
5374 "--search",
5375 query.trim(),
5376 "--json",
5377 "number,title,url",
5378 ]);
5379 serde_json::from_str::<Vec<Row>>(text.trim())
5380 .ok()?
5381 .into_iter()
5382 .find(|row| row.title.trim().to_lowercase() == needle)
5383 .map(|row| row.url)
5384 }
5385
5386 pub fn mark_ready(&self, number: i64) -> bool {
5394 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5395 Ok(_) => true,
5396 Err(e) => {
5397 logdim!(
5398 "PR #{number} is approved but could not be taken out of draft: {}",
5399 e.last_line()
5400 );
5401 false
5402 }
5403 }
5404 }
5405
5406 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5408 let listed = self
5409 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5410 .ok()?;
5411 let wanted = format!("branch refs/heads/{branch}");
5412 let mut current: Option<PathBuf> = None;
5413 for line in listed.lines() {
5414 if let Some(path) = line.strip_prefix("worktree ") {
5415 current = Some(PathBuf::from(path));
5416 } else if line == wanted {
5417 return current;
5418 }
5419 }
5420 None
5421 }
5422
5423 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5439 let branch = self.pr_view(number).ok()?.head_ref_name;
5440 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5441 return None;
5442 }
5443 let mut released = None;
5444 if let Some(path) = self.worktree_holding(&branch) {
5445 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5446 return None;
5447 }
5448 if !matches!(
5449 self.has_recoverable_work(&path, Disposable::BuildOutput),
5450 Ok(false)
5451 ) {
5452 return None;
5453 }
5454 match self.remove_worktree_at(&path) {
5455 Ok(true) => released = Some(path),
5456 _ => return None,
5457 }
5458 }
5459 match self.delete_branch_if_safe(&branch) {
5460 Ok(true) => {
5461 self.forget_branch(&branch);
5462 Some(match released {
5463 Some(path) => format!(
5464 " The worktree at {} was released and branch {branch} deleted.",
5465 path.display()
5466 ),
5467 None => format!(" Branch {branch} was deleted."),
5468 })
5469 }
5470 _ => released.map(|path| {
5471 format!(
5472 " The worktree at {} was released, but branch {branch} was kept.",
5473 path.display()
5474 )
5475 }),
5476 }
5477 }
5478
5479 pub fn merge_pr(&self, number: i64) -> Result<()> {
5483 let n = number.to_string();
5484 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5485 Ok(_) => Ok(()),
5486 Err(e) => {
5487 if self.pr_state(number) == "MERGED" {
5488 let finished = self
5489 .finish_merged_branch_cleanup(number)
5490 .unwrap_or_default();
5491 logdim!(
5492 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5493 e.last_line()
5494 );
5495 Ok(())
5496 } else {
5497 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5498 }
5499 }
5500 };
5501 self.record_write(merged)
5502 }
5503
5504 pub fn merge_pr_at_head(
5506 &self,
5507 number: i64,
5508 expected_head: &str,
5509 delete_branch: bool,
5510 ) -> Result<()> {
5511 let n = number.to_string();
5512 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5513 Ok(_) => Ok(()),
5514 Err(e) => {
5515 if self.pr_state(number) == "MERGED" {
5516 let finished = self
5517 .finish_merged_branch_cleanup(number)
5518 .unwrap_or_default();
5519 logdim!(
5520 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5521 e.last_line()
5522 );
5523 Ok(())
5524 } else {
5525 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5526 }
5527 }
5528 };
5529 self.record_write(merged)
5530 }
5531
5532 pub fn followups_path(&self) -> PathBuf {
5537 self.root.join(STATE_DIR).join("followups.md")
5538 }
5539
5540 pub fn brainstorms_dir(&self) -> PathBuf {
5545 self.root.join(STATE_DIR).join("brainstorms")
5546 }
5547
5548 pub fn worked_followups_path(&self) -> PathBuf {
5556 self.root.join(STATE_DIR).join("followups.done.md")
5557 }
5558
5559 pub fn spend_path(&self) -> PathBuf {
5562 self.root.join(STATE_DIR).join("state").join("spend.json")
5563 }
5564
5565 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5571 if calls.is_empty() {
5572 return Ok(());
5573 }
5574 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5575 .ok()
5576 .and_then(|text| serde_json::from_str(&text).ok())
5577 .unwrap_or_default();
5578 all.extend(calls.iter().cloned());
5579 if let Some(parent) = self.spend_path().parent() {
5580 let _ = std::fs::create_dir_all(parent);
5581 }
5582 write_json_atomic(&self.spend_path(), &all)
5583 }
5584
5585 pub fn implementors_path(&self) -> PathBuf {
5591 self.root
5592 .join(STATE_DIR)
5593 .join("state")
5594 .join("implementors.json")
5595 }
5596
5597 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5598 std::fs::read_to_string(self.implementors_path())
5599 .ok()
5600 .and_then(|text| serde_json::from_str(&text).ok())
5601 .unwrap_or_default()
5602 }
5603
5604 pub fn remember_implementor(&self, issue: i64, agent: &str) {
5607 let mut known = self.read_implementors();
5608 known.insert(issue, agent.to_string());
5609 if let Some(parent) = self.implementors_path().parent() {
5610 let _ = std::fs::create_dir_all(parent);
5611 }
5612 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5613 logdim!("could not record who implemented #{issue}: {e}");
5614 }
5615 }
5616
5617 pub fn contested_path(&self) -> PathBuf {
5622 self.root
5623 .join(STATE_DIR)
5624 .join("state")
5625 .join("contested.json")
5626 }
5627
5628 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5630 std::fs::read_to_string(self.contested_path())
5631 .ok()
5632 .and_then(|text| serde_json::from_str(&text).ok())
5633 .unwrap_or_default()
5634 }
5635
5636 pub fn remember_contested(&self, items: &[ContestedItem]) {
5641 if items.is_empty() {
5642 return;
5643 }
5644 let mut known = self.read_contested();
5645 for item in items {
5646 known.insert(
5647 item.issue,
5648 Contested {
5649 title: item.title.clone(),
5650 positions: item.positions.clone(),
5651 reasons: item.reasons.clone(),
5652 },
5653 );
5654 }
5655 if let Some(parent) = self.contested_path().parent() {
5656 let _ = std::fs::create_dir_all(parent);
5657 }
5658 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5659 logdim!("could not record the contested issues: {e}");
5660 }
5661 }
5662
5663 pub fn forget_contested(&self, numbers: &[i64]) {
5665 let mut known = self.read_contested();
5666 let before = known.len();
5667 known.retain(|number, _| !numbers.contains(number));
5668 if known.len() == before {
5669 return;
5670 }
5671 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5672 logdim!("could not update the contested issues: {e}");
5673 }
5674 }
5675
5676 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5677 self.root
5678 .join(STATE_DIR)
5679 .join("state")
5680 .join(format!("checkin-{number}.json"))
5681 }
5682
5683 pub fn review_record_path(&self, number: i64) -> PathBuf {
5685 self.root
5686 .join(STATE_DIR)
5687 .join("state")
5688 .join(format!("review-{number}.json"))
5689 }
5690
5691 pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5694 let path = self.review_record_path(number);
5695 let text = std::fs::read_to_string(&path).ok()?;
5696 match serde_json::from_str(&text) {
5697 Ok(record) => Some(record),
5698 Err(e) => {
5699 logdim!("could not read {}: {e}", path.display());
5700 None
5701 }
5702 }
5703 }
5704
5705 pub fn write_review_record(
5706 &self,
5707 number: i64,
5708 record: &crate::model::ReviewRecord,
5709 ) -> Result<()> {
5710 write_json_atomic(&self.review_record_path(number), record)
5711 }
5712
5713 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5728 let path = self.followups_path();
5729 let heading = format!("## {}", title.trim());
5730 for seen in [&path, &self.worked_followups_path()] {
5731 if let Ok(existing) = std::fs::read_to_string(seen) {
5732 if has_heading(&existing, title) {
5733 logdim!("follow-up already noted: {title}");
5734 return Followup::Covered(format!("note: {}", title.trim()));
5735 }
5736 }
5737 }
5738 if let Some(parent) = path.parent() {
5739 let _ = std::fs::create_dir_all(parent);
5740 }
5741 use std::io::Write;
5742 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5749 match std::fs::OpenOptions::new()
5750 .create(true)
5751 .append(true)
5752 .open(&path)
5753 {
5754 Ok(mut file) => match file.write_all(entry.as_bytes()) {
5755 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5756 Err(e) => {
5757 logdim!("could not write {}: {e}", path.display());
5758 Followup::Failed
5759 }
5760 },
5761 Err(e) => {
5762 logdim!("could not write {}: {e}", path.display());
5763 Followup::Failed
5764 }
5765 }
5766 }
5767
5768 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5773 let path = self.worked_followups_path();
5774 if let Some(parent) = path.parent() {
5775 let _ = std::fs::create_dir_all(parent);
5776 }
5777 use std::io::Write;
5778 let entry = format!(
5779 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5780 title.trim(),
5781 body.trim()
5782 );
5783 if let Ok(mut file) = std::fs::OpenOptions::new()
5784 .create(true)
5785 .append(true)
5786 .open(&path)
5787 {
5788 let _ = file.write_all(entry.as_bytes());
5789 }
5790 }
5791
5792 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5801 self.root
5802 .join(STATE_DIR)
5803 .join("reviews")
5804 .join(format!("pr-{number}.md"))
5805 }
5806
5807 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5813 let path = self.pending_comment_path(number);
5814 if let Some(parent) = path.parent() {
5815 std::fs::create_dir_all(parent)
5816 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5817 }
5818 std::fs::write(&path, text)
5819 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5820 Ok(path)
5821 }
5822
5823 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5824 std::fs::read_to_string(self.pending_comment_path(number)).ok()
5825 }
5826
5827 pub fn state_path(&self, number: i64) -> PathBuf {
5828 self.root
5829 .join(STATE_DIR)
5830 .join("state")
5831 .join(format!("pr-{number}.json"))
5832 }
5833
5834 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5835 let path = self.state_path(number);
5836 let text = std::fs::read_to_string(&path).ok()?;
5837 match serde_json::from_str(&text) {
5838 Ok(state) => Some(state),
5839 Err(_) => {
5840 logdim!("could not read {}, starting fresh", path.display());
5841 None
5842 }
5843 }
5844 }
5845
5846 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5847 if let Some(local) = self.read_local_state(pr.number) {
5848 return Some(local);
5849 }
5850 if self.state_store.writes_pr() {
5851 return self.read_pr_state(pr.number);
5852 }
5853 None
5854 }
5855
5856 pub(crate) fn read_state_for_head(
5857 &self,
5858 pr: &PrView,
5859 actual_head: &str,
5860 ) -> Option<PersistedState> {
5861 let local = self
5862 .state_store
5863 .writes_local()
5864 .then(|| self.read_local_state(pr.number))
5865 .flatten();
5866 let remote = self
5867 .state_store
5868 .writes_pr()
5869 .then(|| self.read_pr_state(pr.number))
5870 .flatten();
5871 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5872 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5873 self.remember_checkpoint(pr.number, checkpoint);
5874 }
5875 choose_state_for_head(candidates, actual_head)
5876 }
5877
5878 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5879 self.try_read_pr_state(number).ok().flatten()
5880 }
5881
5882 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5883 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5884 if let Some(state) = parse_state_comment(&body) {
5885 return Ok(Some(state));
5886 }
5887 }
5888 Ok(None)
5889 }
5890
5891 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5892 let remote_state = if self.state_store.writes_pr() {
5893 self.try_read_pr_state(number)
5894 } else {
5895 Ok(None)
5896 };
5897 self.write_state_after_remote_read(number, state, remote_state)
5898 }
5899
5900 fn write_state_after_remote_read(
5901 &self,
5902 number: i64,
5903 state: &PersistedState,
5904 remote_state: Result<Option<PersistedState>>,
5905 ) -> Result<()> {
5906 let remote_checkpoint = if self.state_store.writes_pr() {
5907 self.record_failed_write(remote_state)?
5908 .map(|saved| saved.checkpoint)
5909 .unwrap_or_default()
5910 } else {
5911 0
5912 };
5913 let local_checkpoint = self
5914 .state_store
5915 .writes_local()
5916 .then(|| self.read_local_state(number))
5917 .flatten()
5918 .map(|saved| saved.checkpoint)
5919 .unwrap_or_default();
5920 let mut stamped = state.clone();
5921 stamped.checkpoint = state
5922 .checkpoint
5923 .max(local_checkpoint)
5924 .max(remote_checkpoint)
5925 .max(self.remembered_checkpoint(number))
5926 .saturating_add(1);
5927 self.remember_checkpoint(number, stamped.checkpoint);
5928 if self.state_store.writes_local() {
5929 write_json_atomic(&self.state_path(number), &stamped)?;
5930 }
5931 if self.state_store.writes_pr() {
5932 self.write_pr_state(number, &stamped)?;
5933 }
5934 Ok(())
5935 }
5936
5937 fn remembered_checkpoint(&self, number: i64) -> u64 {
5938 self.checkpoints
5939 .lock()
5940 .unwrap_or_else(std::sync::PoisonError::into_inner)
5941 .get(&number)
5942 .copied()
5943 .unwrap_or_default()
5944 }
5945
5946 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
5947 let mut checkpoints = self
5948 .checkpoints
5949 .lock()
5950 .unwrap_or_else(std::sync::PoisonError::into_inner);
5951 let saved = checkpoints.entry(number).or_default();
5952 *saved = (*saved).max(checkpoint);
5953 }
5954
5955 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5956 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
5960 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
5961 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
5962 if let Some(id) = comment_id {
5963 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5964 let field = format!("body={body}");
5965 let written = self
5966 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
5967 .map(|_| ());
5968 return self.record_write(written);
5969 }
5970 let written = self
5971 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
5972 .map(|_| ());
5973 self.record_write(written)
5974 }
5975
5976 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
5984 self.try_issue_comments(number).unwrap_or_default()
5985 }
5986
5987 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
5988 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
5989 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
5990 }
5991
5992 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
5999 let viewer = self.viewer_login()?.to_string();
6000 let mut mine = Vec::new();
6001 let mut theirs = Vec::new();
6002 for comment in self.try_issue_comments(number)? {
6003 let Some(body) = comment.get("body").and_then(Value::as_str) else {
6004 continue;
6005 };
6006 if !body.contains("spar:state") {
6007 continue;
6008 }
6009 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6010 continue;
6011 };
6012 let author = comment
6013 .get("user")
6014 .and_then(|u| u.get("login"))
6015 .and_then(Value::as_str)
6016 .unwrap_or_default();
6017 if is_state_comment(body, author, &viewer) {
6018 mine.push((id, body.to_string()));
6019 } else {
6020 theirs.push(format!("@{author}"));
6021 }
6022 }
6023 if !theirs.is_empty() {
6024 logdim!(
6025 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6026 own state block is patched or deleted.",
6027 theirs.len(),
6028 theirs.join(", ")
6029 );
6030 }
6031 Ok(mine)
6032 }
6033
6034 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6035 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6036 }
6037
6038 pub fn clear_state(&self, number: i64) {
6040 let path = self.state_path(number);
6041 let _ = std::fs::remove_file(&path);
6042 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6043 }
6044
6045 pub fn prune_state(&self) -> Vec<String> {
6052 let base = self.root.join(STATE_DIR).join("state");
6053 let Ok(entries) = std::fs::read_dir(&base) else {
6054 return Vec::new();
6055 };
6056 let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6057 for name in entries
6058 .flatten()
6059 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6060 {
6061 if let Some(number) = state_file_number(&name) {
6062 by_number.entry(number).or_default().push(name);
6063 }
6064 }
6065
6066 let mut removed = Vec::new();
6067 for (number, mut names) in by_number {
6068 if !is_finished(&self.pr_state(number)) {
6069 continue;
6070 }
6071 names.sort();
6072 for name in names {
6073 let _ = std::fs::remove_file(base.join(&name));
6074 removed.push(format!("state {name}"));
6075 }
6076 }
6077 removed
6078 }
6079
6080 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6084 #[derive(Deserialize)]
6085 struct Row {
6086 number: i64,
6087 }
6088 let numbers = match numbers {
6089 Some(numbers) => numbers,
6090 None => {
6091 let listed: Result<Vec<i64>> = (|| {
6092 let text = self.gh(&[
6093 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6094 ])?;
6095 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6096 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6097 Ok(rows.into_iter().map(|row| row.number).collect())
6098 })();
6099 match self.record_failed_write(listed) {
6100 Ok(numbers) => numbers,
6101 Err(e) => {
6102 logdim!("could not inspect pull requests for state cleanup: {e}");
6103 return Vec::new();
6104 }
6105 }
6106 }
6107 };
6108
6109 let mut removed = Vec::new();
6110 for number in numbers {
6111 let state = match self.record_failed_write(self.try_pr_state(number)) {
6112 Ok(state) => state,
6113 Err(e) => {
6114 logdim!("could not inspect PR #{number} for state cleanup: {e}");
6115 continue;
6116 }
6117 };
6118 if !is_finished(&state) {
6119 continue;
6120 }
6121 let comments = match self.record_failed_write(self.try_state_comments(number)) {
6122 Ok(comments) => comments,
6123 Err(e) => {
6124 logdim!("could not inspect state comments on PR #{number}: {e}");
6125 continue;
6126 }
6127 };
6128 for (id, _) in comments {
6129 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6130 let deleted = self
6131 .gh(&["api", "-X", "DELETE", &path, "--silent"])
6132 .map(|_| ());
6133 match self.record_write(deleted) {
6134 Ok(()) => removed.push(format!("state comment on PR #{number}")),
6135 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6136 }
6137 }
6138 }
6139 removed
6140 }
6141
6142 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6149 let base = self.root.join(WORKTREE_DIR);
6150 let mut removed = Vec::new();
6151 let known = self.known_branches();
6152
6153 if let Ok(entries) = std::fs::read_dir(&base) {
6154 let mut names: Vec<String> = entries
6155 .flatten()
6156 .filter(|e| e.path().is_dir())
6157 .filter_map(|e| e.file_name().to_str().map(str::to_string))
6158 .collect();
6159 names.sort();
6160
6161 for name in names {
6162 if let Some(rest) = name.strip_prefix("review-") {
6165 let number: i64 = rest.parse().unwrap_or(-1);
6166 if !(force_all || is_finished(&self.pr_state(number))) {
6167 continue;
6168 }
6169 let path = base.join(&name);
6170 if force_all {
6171 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6172 if !belongs {
6173 return Ok(false);
6174 }
6175 let local_ref = review_ref(number);
6176 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6177 return Ok(false);
6178 }
6179 let head = self.head_oid_checked(&path)?;
6180 let recorded = self
6181 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6182 .trim()
6183 .to_string();
6184 Ok(head == recorded)
6185 });
6186 match owned {
6187 Ok(true) => {}
6188 Ok(false) => {
6189 logdim!(
6190 "kept {} because no matching SPAR review reference proves \
6191 ownership",
6192 path.display()
6193 );
6194 continue;
6195 }
6196 Err(e) => {
6197 logdim!(
6198 "kept {} because review ownership could not be verified: {}",
6199 path.display(),
6200 e.last_line()
6201 );
6202 continue;
6203 }
6204 }
6205 } else {
6206 if let Err(e) = self.refuse_review_worktree_changes(number) {
6207 logdim!(
6208 "kept {} because its review state could not be verified as \
6209 disposable: {}",
6210 path.display(),
6211 e.last_line()
6212 );
6213 continue;
6214 }
6215 }
6216 if force_all {
6217 if self.remove_worktree_at_force(&path) {
6218 self.git_try(&["update-ref", "-d", &review_ref(number)]);
6219 }
6220 } else {
6221 self.release_review_worktree(number);
6222 }
6223 if !path.exists() {
6224 removed.push(name);
6225 }
6226 continue;
6227 }
6228 let branch = format!("{}{name}", self.branch_prefix);
6229 if !(force_all || self.worktree_is_done(&branch)) {
6230 continue;
6231 }
6232 if !known.contains_key(&branch) {
6233 logdim!("kept {branch} because it has no branch record");
6234 continue;
6235 }
6236 let path = base.join(&name);
6237 if !force_all {
6238 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6239 Ok(true) => {
6240 logdim!(
6241 "kept {} because it contains uncommitted changes or ignored files",
6242 path.display()
6243 );
6244 continue;
6245 }
6246 Err(e) => {
6247 logdim!(
6248 "kept {} because its Git state could not be checked: {}",
6249 path.display(),
6250 e.last_line()
6251 );
6252 continue;
6253 }
6254 Ok(false) => {}
6255 }
6256 match self.branch_deletion_is_safe(&branch) {
6257 Ok(true) => {}
6258 Ok(false) => {
6259 logdim!(
6260 "kept {branch} because no surviving ref preserves its tip or \
6261 reflog-only commits"
6262 );
6263 continue;
6264 }
6265 Err(e) => {
6266 logdim!(
6267 "kept {branch} because preservation could not be verified: {}",
6268 e.last_line()
6269 );
6270 continue;
6271 }
6272 }
6273 }
6274 let removed_worktree = if force_all {
6275 self.remove_worktree_at_force(&path)
6276 } else {
6277 match self.remove_worktree_at(&path) {
6278 Ok(removed) => removed,
6279 Err(error) => {
6280 logdim!(
6281 "kept {branch} and {} because removal did not reach a confirmed \
6282 quiet point: {}",
6283 path.display(),
6284 error.last_line()
6285 );
6286 false
6287 }
6288 }
6289 };
6290 if !removed_worktree {
6291 continue;
6292 }
6293 if force_all {
6294 self.git_try(&["branch", "-D", &branch]);
6295 self.forget_branch(&branch);
6296 } else {
6297 match self.delete_branch_if_safe(&branch) {
6298 Ok(true) => self.forget_branch(&branch),
6299 Ok(false) => logdim!(
6300 "kept {branch} because its tip or reflog changed before deletion"
6301 ),
6302 Err(error) => logdim!(
6303 "kept {branch} because deletion safety could not be rechecked: {}",
6304 error.last_line()
6305 ),
6306 }
6307 }
6308 removed.push(name);
6309 }
6310 }
6311 removed.extend(self.prune_branches(force_all));
6312 removed
6313 }
6314
6315 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6322 let known = self.known_branches();
6323 let branches: Vec<String> = known.keys().cloned().collect();
6324 if branches.is_empty() {
6325 return Vec::new();
6326 }
6327
6328 let checked_out: Vec<String> = self
6329 .git_try(&["worktree", "list", "--porcelain"])
6330 .lines()
6331 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6332 .collect();
6333
6334 let existing: Vec<String> = self
6337 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6338 .lines()
6339 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6340 .collect();
6341
6342 let mut removed = Vec::new();
6343 for branch in branches {
6344 if !existing.contains(&branch) {
6345 self.forget_branch(&branch); continue;
6347 }
6348 if checked_out.contains(&branch) {
6349 continue;
6350 }
6351 if !(force_all || self.worktree_is_done(&branch)) {
6352 continue;
6353 }
6354 if !force_all {
6355 let Some(_record) = known.get(&branch) else {
6356 continue;
6357 };
6358 match self.branch_deletion_is_safe(&branch) {
6359 Ok(true) => {}
6360 Ok(false) => {
6361 logdim!(
6362 "kept {branch} because no surviving ref preserves its tip or \
6363 reflog-only commits"
6364 );
6365 continue;
6366 }
6367 Err(e) => {
6368 logdim!(
6369 "kept {branch} because preservation could not be verified: {}",
6370 e.last_line()
6371 );
6372 continue;
6373 }
6374 }
6375 }
6376 let deleted = if force_all {
6377 self.git(&["branch", "-D", &branch]).map(|_| true)
6378 } else {
6379 self.delete_branch_if_safe(&branch)
6380 };
6381 match deleted {
6382 Ok(true) => {
6383 self.forget_branch(&branch);
6384 removed.push(format!("branch {branch}"));
6385 }
6386 Ok(false) => {
6387 logdim!("kept {branch} because its tip or reflog changed before deletion");
6388 }
6389 Err(e) => {
6390 logdim!("could not delete {branch}: {}", e.last_line());
6393 }
6394 }
6395 }
6396 removed
6397 }
6398
6399 fn worktree_is_done(&self, branch: &str) -> bool {
6401 #[derive(Deserialize)]
6402 struct Row {
6403 state: String,
6404 }
6405 let entry = branch
6406 .strip_prefix(self.branch_prefix.as_str())
6407 .unwrap_or(branch);
6408 if let Some(rest) = entry.strip_prefix("pr-") {
6409 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6410 }
6411 if entry.starts_with("issue-") || entry.starts_with("split-") {
6415 let text = self.gh_try(&[
6416 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6417 ]);
6418 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6419 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6420 }
6421 false
6422 }
6423}
6424
6425pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6437 match probe() {
6438 Ok(value) => Ok(value),
6439 Err(first) => {
6440 logdim!("retrying a worktree probe after: {}", first.last_line());
6441 probe()
6442 }
6443 }
6444}
6445
6446pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6452 let root = std::fs::canonicalize(cwd)
6453 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6454 let mut files = BTreeMap::new();
6455 let mut visited = BTreeSet::new();
6456 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6457 Ok(AttributeState { files })
6458}
6459
6460fn collect_attribute_files(
6461 root: &Path,
6462 repository: &Path,
6463 prefix: &Path,
6464 visited: &mut BTreeSet<PathBuf>,
6465 files: &mut BTreeMap<PathBuf, [u8; 32]>,
6466) -> Result<()> {
6467 let canonical = std::fs::canonicalize(repository)
6468 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6469 if !visited.insert(canonical) {
6470 bail!("submodule recursion revisited {}", repository.display());
6471 }
6472 let entries = index_entries(repository)?;
6473 let mut paths: BTreeSet<PathBuf> = entries
6474 .iter()
6475 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6476 .map(|entry| entry.path.clone())
6477 .collect();
6478 paths.extend(untracked_attribute_paths(repository)?);
6484 for path in paths {
6485 let from_root = prefix.join(&path);
6486 let state = attribute_file_fingerprint(&root.join(&from_root))?;
6487 files.insert(from_root, state);
6488 }
6489 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6490 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6491 continue;
6492 };
6493 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6494 }
6495 Ok(())
6496}
6497
6498pub(crate) fn uncertain_worktree_change(
6501 cwd: &Path,
6502 message: impl Into<String>,
6503) -> crate::error::SparError {
6504 let message = message.into();
6505 let marker = write_recovery_marker(cwd, &message);
6506 let note = match marker {
6507 Ok(path) => format!(" Recovery marker: {}.", path.display()),
6508 Err(e) => format!(
6509 " A recovery marker could not be written: {}.",
6510 e.last_line()
6511 ),
6512 };
6513 crate::error::SparError::uncertain_write(format!("{message}{note}"))
6514}
6515
6516fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6517 use std::sync::atomic::{AtomicU32, Ordering};
6518 static NEXT: AtomicU32 = AtomicU32::new(0);
6519 for _ in 0..1000 {
6520 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6521 let path = cwd.join(format!(
6522 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6523 std::process::id()
6524 ));
6525 let mut options = OpenOptions::new();
6526 options.write(true).create_new(true);
6527 #[cfg(unix)]
6528 {
6529 use std::os::unix::fs::OpenOptionsExt;
6530 options.mode(0o600);
6531 }
6532 match options.open(&path) {
6533 Ok(mut file) => {
6534 file.write_all(detail.as_bytes())
6535 .and_then(|_| file.write_all(b"\n"))
6536 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6537 return Ok(path);
6538 }
6539 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6540 Err(e) => {
6541 return Err(spar_err!(
6542 "could not create a recovery marker in {}: {e}",
6543 cwd.display()
6544 ))
6545 }
6546 }
6547 }
6548 bail!(
6549 "could not choose a free recovery marker name in {}",
6550 cwd.display()
6551 )
6552}
6553
6554fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6559 let mut argv = vec![
6560 "git".to_string(),
6561 "-c".to_string(),
6562 "maintenance.auto=false".to_string(),
6563 "-c".to_string(),
6564 "gc.auto=0".to_string(),
6565 ];
6566 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6567 argv
6568}
6569
6570fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6571 let mut argv = git_without_maintenance_argv(&[]);
6572 argv.extend([
6573 "-c".to_string(),
6574 "core.fsmonitor=".to_string(),
6575 "-c".to_string(),
6576 "commit.gpgsign=false".to_string(),
6577 "-c".to_string(),
6578 "core.hooksPath=/dev/null".to_string(),
6579 ]);
6580 argv.extend(args.iter().map(|arg| (*arg).to_string()));
6581 argv
6582}
6583
6584pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6591 let root = std::fs::canonicalize(cwd)
6592 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6593 let mut files = BTreeMap::new();
6594 let mut ignored = BTreeSet::new();
6595 let mut visited = BTreeSet::new();
6596 collect_untracked_files(
6597 &root,
6598 &root,
6599 Path::new(""),
6600 &mut visited,
6601 &mut files,
6602 &mut ignored,
6603 )?;
6604 Ok(IgnoredState { files, ignored })
6605}
6606
6607fn collect_untracked_files(
6608 root: &Path,
6609 repository: &Path,
6610 prefix: &Path,
6611 visited: &mut BTreeSet<PathBuf>,
6612 files: &mut BTreeMap<PathBuf, UntrackedFile>,
6613 ignored: &mut BTreeSet<PathBuf>,
6614) -> Result<()> {
6615 let canonical = std::fs::canonicalize(repository)
6616 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6617 if !visited.insert(canonical.clone()) {
6618 bail!("submodule recursion revisited {}", canonical.display());
6619 }
6620 let listed = run_git_bytes(
6624 repository,
6625 &["ls-files", "--others", "--exclude-standard", "-z"],
6626 )?;
6627 if !listed.is_empty() && !listed.ends_with(&[0]) {
6628 bail!(
6629 "git returned an unterminated untracked-file list for {}",
6630 repository.display()
6631 );
6632 }
6633
6634 for raw in listed
6635 .split(|byte| *byte == 0)
6636 .filter(|raw| !raw.is_empty())
6637 {
6638 let (relative, nested) = untracked_record(raw, "untracked")?;
6639 let from_root = prefix.join(&relative);
6640 if is_spar_own_path(&from_root) {
6641 continue;
6642 }
6643 let absolute = root.join(&from_root);
6644 let fingerprint = if nested {
6645 nested_repository_fingerprint(&absolute)?
6646 } else {
6647 ignored_file_fingerprint(&absolute)?
6648 };
6649 if files.insert(from_root.clone(), fingerprint).is_some() {
6650 bail!(
6651 "git returned the untracked path more than once: {:?}",
6652 from_root
6653 );
6654 }
6655 }
6656
6657 let ignored_listed = run_git_bytes(
6658 repository,
6659 &[
6660 "ls-files",
6661 "--others",
6662 "--ignored",
6663 "--exclude-standard",
6664 "-z",
6665 ],
6666 )?;
6667 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6668 bail!(
6669 "git returned an unterminated ignored-file list for {}",
6670 repository.display()
6671 );
6672 }
6673 for raw in ignored_listed
6679 .split(|byte| *byte == 0)
6680 .filter(|raw| !raw.is_empty())
6681 {
6682 let (relative, nested) = untracked_record(raw, "ignored")?;
6683 let from_root = prefix.join(relative);
6684 if is_spar_own_path(&from_root) {
6685 continue;
6686 }
6687 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
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 slot.insert(fingerprint);
6695 }
6696 if !ignored.insert(from_root.clone()) {
6697 bail!(
6698 "git returned the ignored path more than once: {:?}",
6699 from_root
6700 );
6701 }
6702 }
6703
6704 for link in gitlinks(repository)? {
6705 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6706 continue;
6707 };
6708 collect_untracked_files(
6709 root,
6710 &submodule,
6711 &prefix.join(&link.path),
6712 visited,
6713 files,
6714 ignored,
6715 )?;
6716 }
6717 Ok(())
6718}
6719
6720fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
6721 let argv = git_without_automation_argv(args);
6722 proc::run_bytes(
6723 &argv,
6724 &ExecOpts::new()
6725 .cwd(cwd)
6726 .timeout_secs(30)
6727 .stop_descendants(true),
6728 )
6729}
6730
6731fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
6732 let argv = git_without_automation_argv(args);
6733 proc::run(
6734 &argv,
6735 &ExecOpts::new()
6736 .cwd(cwd)
6737 .timeout_secs(30)
6738 .stop_descendants(true),
6739 )
6740}
6741
6742fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
6743 let path = path.to_str().ok_or_else(|| {
6744 spar_err!(
6745 "cannot verify filtered content for a non-UTF-8 path in {}",
6746 cwd.display()
6747 )
6748 })?;
6749 let path_arg = format!("--path={path}");
6750 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
6751 Ok(Sha256::digest(bytes).into())
6752}
6753
6754fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
6755 let relative = path_from_git_bytes(raw)?;
6756 if relative.is_absolute()
6757 || relative.components().any(|component| {
6758 matches!(
6759 component,
6760 std::path::Component::ParentDir
6761 | std::path::Component::RootDir
6762 | std::path::Component::Prefix(_)
6763 )
6764 })
6765 {
6766 bail!("git returned an unsafe {kind} path: {:?}", relative);
6767 }
6768 Ok(relative)
6769}
6770
6771fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6780 let nested = raw.last() == Some(&b'/');
6781 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6782 if trimmed.is_empty() {
6783 bail!("git returned an empty {kind} path");
6784 }
6785 Ok((safe_git_path(trimmed, kind)?, nested))
6786}
6787
6788fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6789 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6790 if !listed.is_empty() && !listed.ends_with(&[0]) {
6791 bail!(
6792 "git returned an unterminated index listing for {}",
6793 cwd.display()
6794 );
6795 }
6796 let mut entries = Vec::new();
6797 for record in listed
6798 .split(|byte| *byte == 0)
6799 .filter(|record| !record.is_empty())
6800 {
6801 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6802 bail!(
6803 "git returned a malformed index record for {}",
6804 cwd.display()
6805 );
6806 };
6807 let header = &record[..tab];
6808 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6809 if fields.len() != 3 {
6810 bail!(
6811 "git returned a malformed index header for {}",
6812 cwd.display()
6813 );
6814 }
6815 if fields[2] != b"0" {
6816 continue;
6817 }
6818 let mode = std::str::from_utf8(fields[0])
6819 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6820 .to_string();
6821 let oid = std::str::from_utf8(fields[1])
6822 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6823 .to_string();
6824 entries.push(IndexEntry {
6825 path: safe_git_path(&record[tab + 1..], "index")?,
6826 mode,
6827 oid,
6828 });
6829 }
6830 Ok(entries)
6831}
6832
6833fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6835 let listed = run_git_bytes(
6836 cwd,
6837 &[
6838 "ls-files",
6839 "--others",
6840 "--exclude-standard",
6841 "-z",
6842 "--",
6843 ".gitattributes",
6844 ":(glob)**/.gitattributes",
6845 ],
6846 )?;
6847 if !listed.is_empty() && !listed.ends_with(&[0]) {
6848 bail!(
6849 "git returned an unterminated attribute-file listing for {}",
6850 cwd.display()
6851 );
6852 }
6853 listed
6854 .split(|byte| *byte == 0)
6855 .filter(|record| !record.is_empty())
6856 .map(|raw| safe_git_path(raw, "attribute"))
6857 .collect()
6858}
6859
6860fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6861 if !untracked_attribute_paths(cwd)?.is_empty() {
6862 return Ok(true);
6863 }
6864
6865 let index = index_entries(cwd)?
6866 .into_iter()
6867 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6868 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6869 .collect::<BTreeMap<_, _>>();
6870 let head = tree_entries(cwd, "HEAD")?
6871 .into_iter()
6872 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6873 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6874 .collect::<BTreeMap<_, _>>();
6875 if index != head {
6876 return Ok(true);
6877 }
6878
6879 let effective = check_attributes(cwd, index.keys().cloned())?;
6880 let config = CheckoutConfig::read(cwd)?;
6881 for (path, (_mode, oid)) in index {
6882 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6883 return Ok(true);
6884 };
6885 let attributes = effective
6886 .get(&path)
6887 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6888 if allows_expected_crlf(&config, attributes)? {
6889 if worktree.mode == "120000" {
6890 return Ok(true);
6891 }
6892 let (normalized, every_lf_was_crlf) =
6893 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
6894 if !every_lf_was_crlf || normalized != oid {
6895 return Ok(true);
6896 }
6897 } else if worktree.raw_oid != oid {
6898 return Ok(true);
6899 }
6900 }
6901 Ok(false)
6902}
6903
6904fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
6905 Ok(index_entries(cwd)?
6906 .into_iter()
6907 .filter(|entry| entry.mode == "160000")
6908 .map(|entry| Gitlink {
6909 path: entry.path,
6910 oid: entry.oid,
6911 })
6912 .collect())
6913}
6914
6915fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
6916 let mut tracked = BTreeMap::new();
6917 for entry in index_entries(cwd)? {
6918 if entry.mode == "160000" {
6919 continue;
6920 }
6921 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
6922 tracked.insert(
6923 entry.path,
6924 TrackedEntry {
6925 index_mode: entry.mode,
6926 index_oid: entry.oid,
6927 worktree,
6928 },
6929 );
6930 }
6931 Ok(tracked)
6932}
6933
6934fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
6935 let metadata = match std::fs::symlink_metadata(path) {
6936 Ok(metadata) => metadata,
6937 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6938 Err(e) => {
6939 return Err(spar_err!(
6940 "could not inspect tracked file {}: {e}",
6941 path.display()
6942 ))
6943 }
6944 };
6945 let mut fingerprint = Sha256::new();
6946 if metadata.file_type().is_symlink() {
6947 let target = std::fs::read_link(path)
6948 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
6949 let bytes = os_str_bytes(target.as_os_str())?;
6950 fingerprint.update(b"symlink\0");
6951 fingerprint.update(&bytes);
6952 let content = Sha256::digest(&bytes).into();
6953 return Ok(Some(WorktreeFile {
6954 mode: "120000".to_string(),
6955 #[cfg(unix)]
6956 permissions: 0,
6957 raw_oid: git_blob_oid(oid_len, &bytes)?,
6958 fingerprint: fingerprint.finalize().into(),
6959 content,
6960 }));
6961 }
6962 if !metadata.is_file() {
6963 bail!("tracked path {} is not a file or symlink", path.display());
6964 }
6965
6966 let mut options = OpenOptions::new();
6967 options.read(true);
6968 #[cfg(unix)]
6969 {
6970 use std::os::unix::fs::OpenOptionsExt;
6971 options.custom_flags(libc::O_NOFOLLOW);
6972 }
6973 let mut file = options
6974 .open(path)
6975 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6976 let before = file
6977 .metadata()
6978 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6979 let mode = tracked_file_mode(&before);
6980 #[cfg(unix)]
6981 let permissions = {
6982 use std::os::unix::fs::MetadataExt;
6983 before.mode() & 0o7777
6984 };
6985 fingerprint.update(b"file\0");
6986 fingerprint.update(mode.as_bytes());
6987 #[cfg(unix)]
6988 fingerprint.update(permissions.to_le_bytes());
6989 fingerprint.update(before.len().to_le_bytes());
6990 let mut content = Sha256::new();
6991 let header = format!("blob {}\0", before.len());
6992 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6993 let mut buf = [0u8; 64 * 1024];
6994 loop {
6995 let read = file
6996 .read(&mut buf)
6997 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6998 if read == 0 {
6999 break;
7000 }
7001 fingerprint.update(&buf[..read]);
7002 content.update(&buf[..read]);
7003 object.update(&buf[..read]);
7004 }
7005 let after = file
7006 .metadata()
7007 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7008 if before.len() != after.len()
7009 || before.modified().ok() != after.modified().ok()
7010 || before.permissions() != after.permissions()
7011 {
7012 bail!(
7013 "tracked file {} changed while it was being inspected",
7014 path.display()
7015 );
7016 }
7017 let current = std::fs::symlink_metadata(path)
7018 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7019 if !same_file(&after, ¤t) {
7020 bail!(
7021 "tracked file {} was replaced while it was being inspected",
7022 path.display()
7023 );
7024 }
7025 Ok(Some(WorktreeFile {
7026 mode,
7027 #[cfg(unix)]
7028 permissions,
7029 raw_oid: object.finish(),
7030 fingerprint: fingerprint.finalize().into(),
7031 content: content.finalize().into(),
7032 }))
7033}
7034
7035fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7036 let metadata = std::fs::symlink_metadata(path)
7037 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7038 let mut digest = Sha256::new();
7039 if metadata.file_type().is_symlink() {
7040 digest.update(b"symlink\0");
7041 let target = std::fs::read_link(path)
7042 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7043 digest.update(os_str_bytes(target.as_os_str())?);
7044 return Ok(digest.finalize().into());
7045 }
7046 if !metadata.is_file() {
7047 bail!("attribute path {} is not a file or symlink", path.display());
7048 }
7049 let mut options = OpenOptions::new();
7050 options.read(true);
7051 #[cfg(unix)]
7052 {
7053 use std::os::unix::fs::OpenOptionsExt;
7054 options.custom_flags(libc::O_NOFOLLOW);
7055 }
7056 let mut file = options
7057 .open(path)
7058 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7059 let before = file
7060 .metadata()
7061 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7062 digest.update(b"file\0");
7063 let mut buf = [0u8; 64 * 1024];
7064 loop {
7065 let read = file
7066 .read(&mut buf)
7067 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7068 if read == 0 {
7069 break;
7070 }
7071 digest.update(&buf[..read]);
7072 }
7073 let after = file
7074 .metadata()
7075 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7076 let current = std::fs::symlink_metadata(path)
7077 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7078 if before.len() != after.len()
7079 || before.modified().ok() != after.modified().ok()
7080 || !same_file(&after, ¤t)
7081 {
7082 bail!(
7083 "attribute file {} changed while it was being inspected",
7084 path.display()
7085 );
7086 }
7087 Ok(digest.finalize().into())
7088}
7089
7090enum ObjectHasher {
7091 Sha1(Sha1),
7092 Sha256(Sha256),
7093}
7094
7095impl ObjectHasher {
7096 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7097 let mut hasher = match oid_len {
7098 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7099 64 => Self::Sha256(Sha256::new()),
7100 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7101 };
7102 hasher.update(header);
7103 Ok(hasher)
7104 }
7105
7106 fn update(&mut self, bytes: &[u8]) {
7107 match self {
7108 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7109 Self::Sha256(hasher) => hasher.update(bytes),
7110 }
7111 }
7112
7113 fn finish(self) -> String {
7114 let bytes = match self {
7115 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7116 Self::Sha256(hasher) => hasher.finalize().to_vec(),
7117 };
7118 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7119 }
7120}
7121
7122fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7123 let header = format!("blob {}\0", bytes.len());
7124 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7125 hasher.update(bytes);
7126 Ok(hasher.finish())
7127}
7128
7129fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7130 let mut first = open_regular_file(path)?;
7131 let first_before = first
7132 .metadata()
7133 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7134 let mut raw_len = 0u64;
7135 let mut crlf_pairs = 0u64;
7136 let mut previous_was_cr = false;
7137 let mut every_lf_was_crlf = true;
7138 let mut buf = [0u8; 64 * 1024];
7139 loop {
7140 let read = first
7141 .read(&mut buf)
7142 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7143 if read == 0 {
7144 break;
7145 }
7146 raw_len = raw_len
7147 .checked_add(read as u64)
7148 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7149 for byte in &buf[..read] {
7150 if *byte == b'\n' {
7151 if previous_was_cr {
7152 crlf_pairs += 1;
7153 } else {
7154 every_lf_was_crlf = false;
7155 }
7156 }
7157 previous_was_cr = *byte == b'\r';
7158 }
7159 }
7160 let first_after = first
7161 .metadata()
7162 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7163 let current = std::fs::symlink_metadata(path)
7164 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7165 if raw_len != first_before.len()
7166 || !stable_file_metadata(&first_before, &first_after)
7167 || !stable_file_metadata(&first_after, ¤t)
7168 {
7169 bail!(
7170 "tracked file {} changed while line endings were inspected",
7171 path.display()
7172 );
7173 }
7174
7175 let normalized_len = raw_len
7176 .checked_sub(crlf_pairs)
7177 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7178 let header = format!("blob {normalized_len}\0");
7179 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7180 let mut second = open_regular_file(path)?;
7181 let second_before = second
7182 .metadata()
7183 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7184 if !stable_file_metadata(&first_after, &second_before) {
7185 bail!(
7186 "tracked file {} changed between line-ending checks",
7187 path.display()
7188 );
7189 }
7190 let mut pending_cr = false;
7191 loop {
7192 let read = second
7193 .read(&mut buf)
7194 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7195 if read == 0 {
7196 break;
7197 }
7198 for byte in &buf[..read] {
7199 if pending_cr {
7200 if *byte == b'\n' {
7201 object.update(b"\n");
7202 pending_cr = false;
7203 continue;
7204 }
7205 object.update(b"\r");
7206 pending_cr = false;
7207 }
7208 if *byte == b'\r' {
7209 pending_cr = true;
7210 } else {
7211 object.update(std::slice::from_ref(byte));
7212 }
7213 }
7214 }
7215 if pending_cr {
7216 object.update(b"\r");
7217 }
7218 let second_after = second
7219 .metadata()
7220 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7221 let current = std::fs::symlink_metadata(path)
7222 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7223 if !stable_file_metadata(&second_before, &second_after)
7224 || !stable_file_metadata(&second_after, ¤t)
7225 {
7226 bail!(
7227 "tracked file {} changed while line endings were hashed",
7228 path.display()
7229 );
7230 }
7231 Ok((object.finish(), every_lf_was_crlf))
7232}
7233
7234fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7235 let mut options = OpenOptions::new();
7236 options.read(true);
7237 #[cfg(unix)]
7238 {
7239 use std::os::unix::fs::OpenOptionsExt;
7240 options.custom_flags(libc::O_NOFOLLOW);
7241 }
7242 let file = options
7243 .open(path)
7244 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7245 let metadata = file
7246 .metadata()
7247 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7248 if !metadata.is_file() {
7249 bail!("tracked path {} is not a regular file", path.display());
7250 }
7251 Ok(file)
7252}
7253
7254fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7255 if !same_file(left, right)
7256 || left.len() != right.len()
7257 || left.modified().ok() != right.modified().ok()
7258 || left.permissions() != right.permissions()
7259 {
7260 return false;
7261 }
7262 #[cfg(unix)]
7263 {
7264 use std::os::unix::fs::MetadataExt;
7265 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7266 }
7267 #[cfg(not(unix))]
7268 {
7269 left.created().ok() == right.created().ok()
7270 }
7271}
7272
7273const ATTRIBUTE_BATCH: usize = 1000;
7280
7281fn check_attributes(
7282 cwd: &Path,
7283 paths: impl IntoIterator<Item = PathBuf>,
7284) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7285 let paths = paths.into_iter().collect::<BTreeSet<_>>();
7286 if paths.is_empty() {
7287 return Ok(BTreeMap::new());
7288 }
7289 let ordered = paths.into_iter().collect::<Vec<_>>();
7290 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7291 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7292 values.extend(check_attribute_batch(cwd, batch)?);
7293 }
7294 Ok(values)
7295}
7296
7297fn check_attribute_batch(
7298 cwd: &Path,
7299 batch: &[PathBuf],
7300) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7301 const NAMES: [&str; 6] = [
7302 "filter",
7303 "working-tree-encoding",
7304 "ident",
7305 "text",
7306 "eol",
7307 "crlf",
7308 ];
7309 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7310 let mut input = String::new();
7311 for path in &paths {
7312 let path = path.to_str().ok_or_else(|| {
7313 spar_err!(
7314 "cannot inspect attributes for a non-UTF-8 path in {}",
7315 cwd.display()
7316 )
7317 })?;
7318 input.push_str(path);
7319 input.push('\0');
7320 }
7321 let argv = git_without_automation_argv(&[
7322 "check-attr",
7323 "-z",
7324 "--cached",
7325 "--stdin",
7326 "filter",
7327 "working-tree-encoding",
7328 "ident",
7329 "text",
7330 "eol",
7331 "crlf",
7332 ]);
7333 let output = proc::run_bytes(
7334 &argv,
7335 &ExecOpts::new()
7336 .cwd(cwd)
7337 .timeout_secs(30)
7338 .stdin(input)
7339 .stop_descendants(true),
7340 )?;
7341 if !output.is_empty() && !output.ends_with(&[0]) {
7342 bail!(
7343 "git returned an unterminated attribute result for {}",
7344 cwd.display()
7345 );
7346 }
7347 let fields = output
7348 .split(|byte| *byte == 0)
7349 .filter(|field| !field.is_empty())
7350 .collect::<Vec<_>>();
7351 if fields.len() != paths.len() * NAMES.len() * 3 {
7352 bail!(
7353 "git returned an unexpected attribute result for {}",
7354 cwd.display()
7355 );
7356 }
7357 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7358 for record in fields.chunks_exact(3) {
7359 let path = safe_git_path(record[0], "attribute")?;
7360 if !paths.contains(&path) {
7361 bail!(
7362 "git returned attributes for the wrong path in {}",
7363 cwd.display()
7364 );
7365 }
7366 let name = std::str::from_utf8(record[1])
7367 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7368 let value = std::str::from_utf8(record[2])
7369 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7370 values
7371 .entry(path)
7372 .or_default()
7373 .insert(name.to_string(), value.to_string());
7374 }
7375 if paths.iter().any(|path| {
7376 values
7377 .get(path)
7378 .is_none_or(|attributes| attributes.len() != NAMES.len())
7379 }) {
7380 bail!(
7381 "git omitted an attribute result for a tracked path in {}",
7382 cwd.display()
7383 );
7384 }
7385 Ok(values)
7386}
7387
7388fn attribute_is_active(value: Option<&String>) -> bool {
7389 !matches!(
7390 value.map(String::as_str),
7391 None | Some("unspecified") | Some("unset")
7392 )
7393}
7394
7395fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7396 attribute_is_active(values.get("filter"))
7397 || attribute_is_active(values.get("working-tree-encoding"))
7398}
7399
7400struct CheckoutConfig {
7409 autocrlf: Option<String>,
7410 eol: Option<String>,
7411 symlinks: Option<bool>,
7412}
7413
7414impl CheckoutConfig {
7415 fn read(cwd: &Path) -> Result<Self> {
7416 Ok(Self {
7417 autocrlf: git_config_value(cwd, "core.autocrlf")?,
7418 eol: git_config_value(cwd, "core.eol")?,
7419 symlinks: git_config_bool(cwd, "core.symlinks")?,
7420 })
7421 }
7422
7423 fn autocrlf_is_true(&self) -> bool {
7424 self.autocrlf.as_ref().is_some_and(|value| {
7425 matches!(
7426 value.to_ascii_lowercase().as_str(),
7427 "true" | "yes" | "on" | "1"
7428 )
7429 })
7430 }
7431
7432 fn eol_is(&self, wanted: &str) -> bool {
7433 self.eol
7434 .as_ref()
7435 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7436 }
7437}
7438
7439fn path_has_ambiguous_transform(
7440 config: &CheckoutConfig,
7441 values: &BTreeMap<String, String>,
7442) -> Result<bool> {
7443 if path_has_external_transform(values)
7444 || attribute_is_active(values.get("ident"))
7445 || attribute_is_active(values.get("crlf"))
7446 {
7447 return Ok(true);
7448 }
7449 let text = values.get("text").map(String::as_str);
7450 let eol = values.get("eol").map(String::as_str);
7451 if text == Some("auto") {
7452 return Ok(true);
7453 }
7454 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7455 || !matches!(
7456 eol,
7457 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7458 )
7459 {
7460 return Ok(true);
7461 }
7462 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7463 return Ok(config.autocrlf_is_true());
7464 }
7465 Ok(false)
7466}
7467
7468fn allows_expected_crlf(
7469 config: &CheckoutConfig,
7470 values: &BTreeMap<String, String>,
7471) -> Result<bool> {
7472 if path_has_external_transform(values)
7473 || attribute_is_active(values.get("ident"))
7474 || attribute_is_active(values.get("crlf"))
7475 {
7476 return Ok(false);
7477 }
7478 let text = values.get("text").map(String::as_str);
7479 let eol = values.get("eol").map(String::as_str);
7480 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7481 return Ok(false);
7482 }
7483 if eol == Some("crlf") {
7484 return Ok(true);
7485 }
7486 if text != Some("set") {
7487 return Ok(false);
7488 }
7489 if let Some(autocrlf) = config.autocrlf.as_deref() {
7490 match autocrlf.to_ascii_lowercase().as_str() {
7491 "true" | "yes" | "on" | "1" => return Ok(true),
7492 "input" => return Ok(false),
7493 _ => {}
7494 }
7495 }
7496 if config.eol_is("crlf") {
7497 return Ok(true);
7498 }
7499 #[cfg(windows)]
7500 if config.eol.is_none() || config.eol_is("native") {
7501 return Ok(true);
7502 }
7503 Ok(false)
7504}
7505
7506fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7507 let argv = git_without_automation_argv(&["config", "--get", key]);
7508 let output = proc::exec(
7509 &argv,
7510 &ExecOpts::new()
7511 .cwd(cwd)
7512 .timeout_secs(30)
7513 .check(false)
7514 .stop_descendants(true),
7515 )?;
7516 match output.code {
7517 0 => Ok(Some(output.stdout.trim().to_string())),
7518 1 => Ok(None),
7519 _ => bail!(
7520 "could not read Git configuration in {}: {}",
7521 cwd.display(),
7522 output.stderr.trim()
7523 ),
7524 }
7525}
7526
7527fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7528 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7529 let output = proc::exec(
7530 &argv,
7531 &ExecOpts::new()
7532 .cwd(cwd)
7533 .timeout_secs(30)
7534 .check(false)
7535 .stop_descendants(true),
7536 )?;
7537 match output.code {
7538 0 if output.stdout.trim() == "true" => Ok(Some(true)),
7539 0 if output.stdout.trim() == "false" => Ok(Some(false)),
7540 0 => bail!(
7541 "git returned an invalid boolean for {key} in {}",
7542 cwd.display()
7543 ),
7544 1 => Ok(None),
7545 _ => bail!(
7546 "could not read Git configuration in {}: {}",
7547 cwd.display(),
7548 output.stderr.trim()
7549 ),
7550 }
7551}
7552
7553#[cfg(unix)]
7554fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7555 use std::os::unix::fs::PermissionsExt;
7556 if metadata.permissions().mode() & 0o111 == 0 {
7557 "100644".to_string()
7558 } else {
7559 "100755".to_string()
7560 }
7561}
7562
7563#[cfg(not(unix))]
7564fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7565 "100644".to_string()
7566}
7567
7568fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7569 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7570 if !listed.is_empty() && !listed.ends_with(&[0]) {
7571 bail!(
7572 "git returned an unterminated tree listing for {}",
7573 cwd.display()
7574 );
7575 }
7576 let mut entries = Vec::new();
7577 for record in listed
7578 .split(|byte| *byte == 0)
7579 .filter(|record| !record.is_empty())
7580 {
7581 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7582 bail!("git returned a malformed tree record for {}", cwd.display());
7583 };
7584 let fields = record[..tab]
7585 .split(|byte| *byte == b' ')
7586 .collect::<Vec<_>>();
7587 if fields.len() != 3 {
7588 bail!("git returned a malformed tree header for {}", cwd.display());
7589 }
7590 let mode = std::str::from_utf8(fields[0])
7591 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7592 .to_string();
7593 let oid = std::str::from_utf8(fields[2])
7594 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7595 .to_string();
7596 entries.push(IndexEntry {
7597 path: safe_git_path(&record[tab + 1..], "tree")?,
7598 mode,
7599 oid,
7600 });
7601 }
7602 Ok(entries)
7603}
7604
7605fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7606 Ok(tree_entries(cwd, "HEAD")?
7607 .into_iter()
7608 .filter(|entry| entry.mode == "160000")
7609 .map(|entry| (entry.path, entry.oid))
7610 .collect())
7611}
7612
7613fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7614 let head = head_gitlinks(cwd)?;
7615 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7616 .into_iter()
7617 .map(|link| (link.path, link.oid))
7618 .collect();
7619 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7620 paths.extend(index.keys().cloned());
7621 Ok(paths
7622 .into_iter()
7623 .filter(|path| head.get(path) != index.get(path))
7624 .collect())
7625}
7626
7627fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7628 let path = parent.join(relative);
7629 let metadata = match std::fs::symlink_metadata(&path) {
7630 Ok(metadata) => metadata,
7631 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7632 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7633 };
7634 if !metadata.is_dir() {
7635 bail!("the gitlink at {} is not a directory", path.display());
7636 }
7637 let canonical = std::fs::canonicalize(&path)
7638 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7639 if canonical != path {
7640 bail!(
7641 "the gitlink at {} resolves through a symlink",
7642 path.display()
7643 );
7644 }
7645 if !path.join(".git").exists() {
7646 let empty = std::fs::read_dir(&path)
7647 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7648 .next()
7649 .is_none();
7650 if empty {
7651 return Ok(None);
7652 }
7653 bail!(
7654 "the uninitialized gitlink at {} contains local files",
7655 path.display()
7656 );
7657 }
7658 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7659 if inside.trim() != "true" {
7660 bail!("the gitlink at {} is not a worktree", path.display());
7661 }
7662 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7663 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7664 spar_err!(
7665 "could not resolve the gitlink top level at {}: {e}",
7666 path.display()
7667 )
7668 })?;
7669 if top != canonical {
7670 bail!(
7671 "the gitlink at {} belongs to a different worktree",
7672 path.display()
7673 );
7674 }
7675 Ok(Some(canonical))
7676}
7677
7678fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
7692 let root = std::fs::canonicalize(cwd)
7693 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7694 let mut allowed = BTreeSet::from([root.join(".git")]);
7695 let mut checkouts = BTreeSet::new();
7696 let mut repositories = vec![root.clone()];
7697 let mut visited = BTreeSet::new();
7698 while let Some(repository) = repositories.pop() {
7699 let canonical = std::fs::canonicalize(&repository)
7700 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7701 if !visited.insert(canonical.clone()) {
7702 bail!("submodule recursion revisited {}", canonical.display());
7703 }
7704 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
7705 let (path, nested) = untracked_record(&raw, "untracked")?;
7706 if nested {
7707 checkouts.insert(canonical.join(path));
7708 }
7709 }
7710 for link in gitlinks(&canonical)? {
7711 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
7712 continue;
7713 };
7714 allowed.insert(submodule.join(".git"));
7715 repositories.push(submodule);
7716 }
7717 }
7718
7719 let scan_root = root.clone();
7723 let mut directories = vec![root];
7724 while let Some(directory) = directories.pop() {
7725 let entries = match std::fs::read_dir(&directory) {
7726 Ok(entries) => entries,
7727 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7728 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7729 };
7730 for entry in entries {
7731 let entry = match entry {
7732 Ok(entry) => entry,
7733 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7734 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7735 };
7736 let path = entry.path();
7737 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
7738 continue;
7739 }
7740 if entry.file_name() == OsStr::new(".git") {
7741 if !allowed.contains(&path) {
7742 return Ok(Some(path));
7743 }
7744 continue;
7745 }
7746 let kind = match entry.file_type() {
7747 Ok(kind) => kind,
7748 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7749 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7750 };
7751 if kind.is_dir() && !checkouts.contains(&path) {
7752 directories.push(path);
7753 }
7754 }
7755 }
7756 Ok(None)
7757}
7758
7759pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
7760 if let Some(path) = unexpected_nested_git_entry(cwd)? {
7761 bail!(
7762 "the worktree contains a Git entry at {} that Git does not report as a nested \
7763 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
7764 prove they are unchanged.",
7765 path.display()
7766 );
7767 }
7768 let root = std::fs::canonicalize(cwd)
7769 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7770 let mut repositories = BTreeMap::new();
7771 let mut visited = BTreeSet::new();
7772 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7773 Ok(GitState { repositories })
7774}
7775
7776fn collect_git_state(
7777 repository: &Path,
7778 prefix: &Path,
7779 visited: &mut BTreeSet<PathBuf>,
7780 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7781) -> Result<()> {
7782 let canonical = std::fs::canonicalize(repository)
7783 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7784 if !visited.insert(canonical.clone()) {
7785 bail!("submodule recursion revisited {}", canonical.display());
7786 }
7787 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7788 let head = head.trim().to_string();
7789 if head.is_empty() {
7790 bail!("git returned an empty head for {}", repository.display());
7791 }
7792 let unsafe_index_flags = unsafe_index_flags(repository)?;
7793 let tracked = tracked_entries(repository)?;
7794 let gitlinks = gitlinks(repository)?;
7795 if repositories
7796 .insert(
7797 prefix.to_path_buf(),
7798 RepositoryState {
7799 head,
7800 unsafe_index_flags,
7801 tracked,
7802 gitlinks: gitlinks
7803 .iter()
7804 .map(|link| (link.path.clone(), link.oid.clone()))
7805 .collect(),
7806 },
7807 )
7808 .is_some()
7809 {
7810 bail!("Git state contains duplicate repository path {:?}", prefix);
7811 }
7812
7813 for link in gitlinks {
7814 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7815 continue;
7816 };
7817 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7818 }
7819 Ok(())
7820}
7821
7822fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7823 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7824 if !listed.is_empty() && !listed.ends_with(&[0]) {
7825 bail!(
7826 "git returned an unterminated index-flag listing for {}",
7827 cwd.display()
7828 );
7829 }
7830 let mut unsafe_records = Vec::new();
7831 for record in listed
7832 .split(|byte| *byte == 0)
7833 .filter(|record| !record.is_empty())
7834 {
7835 if record.len() < 3 || record[1] != b' ' {
7836 bail!(
7837 "git returned a malformed index-flag record for {}",
7838 cwd.display()
7839 );
7840 }
7841 if record[0] != b'H' {
7842 unsafe_records.extend_from_slice(record);
7843 unsafe_records.push(0);
7844 }
7845 }
7846 Ok(unsafe_records)
7847}
7848
7849pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7850 safe_git_state(cwd).map(|_| ())
7851}
7852
7853pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7854 let state = git_state(cwd)?;
7855 if let Some((path, _repository)) = state
7856 .repositories
7857 .iter()
7858 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7859 {
7860 let label = if path.as_os_str().is_empty() {
7861 cwd.to_path_buf()
7862 } else {
7863 cwd.join(path)
7864 };
7865 bail!(
7866 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7867 SPAR cannot prove the working files are unchanged, so it was kept.",
7868 label.display()
7869 );
7870 }
7871 Ok(state)
7872}
7873
7874fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7875 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7876}
7877
7878fn repository_has_recoverable_work_with(
7879 cwd: &Path,
7880 include_ignored: bool,
7881 disposable: Disposable,
7882) -> Result<bool> {
7883 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7884 return Ok(true);
7885 }
7886 let mut visited = BTreeSet::new();
7887 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7888}
7889
7890fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7891 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7892 let git_dir = PathBuf::from(git_dir.trim());
7893 let git_dir = if git_dir.is_absolute() {
7894 git_dir
7895 } else {
7896 cwd.join(git_dir)
7897 };
7898 let git_dir = std::fs::canonicalize(&git_dir)
7899 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
7900 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
7901 Ok(_) => return Ok(true),
7902 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7903 Err(error) => {
7904 return Err(spar_err!(
7905 "could not inspect per-worktree configuration in {}: {error}",
7906 git_dir.display()
7907 ))
7908 }
7909 }
7910
7911 let orig_head = git_dir.join("ORIG_HEAD");
7912 match std::fs::symlink_metadata(&orig_head) {
7913 Ok(metadata) if metadata.is_file() => {
7914 let oid = std::fs::read_to_string(&orig_head)
7915 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
7916 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
7917 return Ok(true);
7918 };
7919 if !commit_has_shared_ref(cwd, &commit)? {
7920 return Ok(true);
7921 }
7922 }
7923 Ok(_) => return Ok(true),
7924 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7925 Err(error) => {
7926 return Err(spar_err!(
7927 "could not inspect {}: {error}",
7928 orig_head.display()
7929 ))
7930 }
7931 }
7932
7933 let edit_message = git_dir.join("COMMIT_EDITMSG");
7934 match std::fs::symlink_metadata(&edit_message) {
7935 Ok(metadata) if metadata.is_file() => {
7936 let draft = std::fs::read(&edit_message)
7937 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
7938 if draft != head_commit_message(cwd)? {
7939 return Ok(true);
7940 }
7941 }
7942 Ok(_) => return Ok(true),
7943 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7944 Err(error) => {
7945 return Err(spar_err!(
7946 "could not inspect {}: {error}",
7947 edit_message.display()
7948 ))
7949 }
7950 }
7951
7952 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
7953 return Ok(true);
7954 }
7955
7956 let local_refs = run_git_bytes(
7957 cwd,
7958 &[
7959 "for-each-ref",
7960 "--format=%(refname)",
7961 "refs/worktree",
7962 "refs/bisect",
7963 "refs/rewritten",
7964 ],
7965 )?;
7966 if !local_refs.is_empty() {
7967 return Ok(true);
7968 }
7969
7970 for entry in std::fs::read_dir(&git_dir)
7974 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
7975 {
7976 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
7977 let known = matches!(
7978 entry.file_name().to_str(),
7979 Some(
7980 "HEAD"
7981 | "ORIG_HEAD"
7982 | "COMMIT_EDITMSG"
7983 | "FETCH_HEAD"
7984 | "commondir"
7985 | "gitdir"
7986 | "index"
7987 | "logs"
7988 | "refs"
7989 )
7990 );
7991 if !known {
7992 return Ok(true);
7993 }
7994 }
7995
7996 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7997 if !commit_has_shared_ref(cwd, head.trim())? {
7998 return Ok(true);
7999 }
8000 Ok(false)
8001}
8002
8003fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8004 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8005 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8006 bail!(
8007 "git returned a commit without a message separator in {}",
8008 cwd.display()
8009 );
8010 };
8011 Ok(commit[split + 2..].to_vec())
8012}
8013
8014fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8015 let metadata = match std::fs::symlink_metadata(logs) {
8016 Ok(metadata) => metadata,
8017 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8018 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8019 };
8020 if !metadata.is_dir() {
8021 return Ok(true);
8022 }
8023 let mut files = Vec::new();
8024 let mut directories = vec![logs.to_path_buf()];
8025 while let Some(directory) = directories.pop() {
8026 for entry in std::fs::read_dir(&directory)
8027 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8028 {
8029 let entry =
8030 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8031 let path = entry.path();
8032 let kind = entry
8033 .file_type()
8034 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8035 if kind.is_dir() {
8036 directories.push(path);
8037 } else if kind.is_file() {
8038 files.push(path);
8039 } else {
8040 return Ok(true);
8041 }
8042 }
8043 }
8044
8045 let mut commits = BTreeSet::new();
8046 for path in files {
8047 if !collect_reflog_commits(cwd, &path, &mut commits)? {
8048 return Ok(true);
8049 }
8050 }
8051 for commit in commits {
8052 if !commit_has_shared_ref(cwd, &commit)? {
8053 return Ok(true);
8054 }
8055 }
8056 Ok(false)
8057}
8058
8059fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8063 let common = common_git_dir(cwd)?;
8064 let reflog = common.join("logs").join(refname);
8065 let metadata = match std::fs::symlink_metadata(&reflog) {
8066 Ok(metadata) => metadata,
8067 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8068 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8069 };
8070 if !metadata.is_file() {
8071 return Ok(false);
8072 }
8073 let mut commits = BTreeSet::new();
8074 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8075 return Ok(false);
8076 }
8077 for commit in commits {
8078 if is_ancestor(cwd, &commit, durable_tip)?
8079 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8080 {
8081 continue;
8082 }
8083 return Ok(false);
8084 }
8085 Ok(true)
8086}
8087
8088fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8089 let file = std::fs::File::open(path)
8090 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8091 for line in std::io::BufReader::new(file).lines() {
8092 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8093 let mut fields = line.splitn(3, ' ');
8094 let Some(old) = fields.next() else {
8095 return Ok(false);
8096 };
8097 let Some(new) = fields.next() else {
8098 return Ok(false);
8099 };
8100 if fields.next().is_none() {
8101 return Ok(false);
8102 }
8103 for oid in [old, new] {
8104 if oid.bytes().all(|byte| byte == b'0') {
8105 continue;
8106 }
8107 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8108 return Ok(false);
8109 };
8110 commits.insert(commit);
8111 }
8112 }
8113 Ok(true)
8114}
8115
8116fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8117 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8118 let path = PathBuf::from(raw.trim());
8119 let path = if path.is_absolute() {
8120 path
8121 } else {
8122 cwd.join(path)
8123 };
8124 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8125}
8126
8127fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8128 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8129 let output = proc::exec(
8130 &argv,
8131 &ExecOpts::new()
8132 .cwd(cwd)
8133 .timeout_secs(30)
8134 .check(false)
8135 .stop_descendants(true),
8136 )?;
8137 match output.code {
8138 0 => Ok(true),
8139 1 => Ok(false),
8140 _ => bail!("{}", proc::failure_message(&argv, &output)),
8141 }
8142}
8143
8144fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8145 let commit = format!("{oid}^{{commit}}");
8146 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8147 let output = proc::exec(
8148 &argv,
8149 &ExecOpts::new()
8150 .cwd(cwd)
8151 .timeout_secs(30)
8152 .check(false)
8153 .stop_descendants(true),
8154 )?;
8155 if output.code != 0 {
8156 return Ok(None);
8157 }
8158 let oid = output.stdout.trim();
8159 if oid.is_empty() {
8160 return Ok(None);
8161 }
8162 Ok(Some(oid.to_string()))
8163}
8164
8165fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8166 commit_has_shared_ref_except(cwd, oid, None)
8167}
8168
8169fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8170 let contains = format!("--contains={oid}");
8171 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8172 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8173 !record.is_empty()
8174 && !record.starts_with(b"refs/worktree/")
8175 && !record.starts_with(b"refs/bisect/")
8176 && !record.starts_with(b"refs/rewritten/")
8177 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8178 }))
8179}
8180
8181fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8196 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8197 for raw in &ordinary {
8198 let (path, _) = untracked_record(raw, "untracked")?;
8199 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8200 continue;
8201 }
8202 return Ok(true);
8203 }
8204 let listed = untracked_listing(
8205 cwd,
8206 &[
8207 "ls-files",
8208 "--others",
8209 "--ignored",
8210 "--exclude-standard",
8211 "-z",
8212 ],
8213 )?;
8214 for raw in listed {
8215 let (path, nested) = untracked_record(&raw, "ignored")?;
8216 if nested {
8220 return Ok(true);
8221 }
8222 if disposable == Disposable::BuildOutput
8223 && !is_generated_artifact(&path)
8224 && !is_desktop_metadata(&path)
8225 {
8226 return Ok(true);
8227 }
8228 }
8229 Ok(false)
8230}
8231
8232fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8233 let listed = run_git_bytes(cwd, args)?;
8234 if !listed.is_empty() && !listed.ends_with(&[0]) {
8235 bail!(
8236 "git returned an unterminated untracked-file list for {}",
8237 cwd.display()
8238 );
8239 }
8240 Ok(listed
8241 .split(|byte| *byte == 0)
8242 .filter(|raw| !raw.is_empty())
8243 .map(|raw| raw.to_vec())
8244 .collect())
8245}
8246
8247fn repository_has_recoverable_work_inner(
8248 cwd: &Path,
8249 include_ignored: bool,
8250 disposable: Disposable,
8251 visited: &mut BTreeSet<PathBuf>,
8252) -> Result<bool> {
8253 let canonical = std::fs::canonicalize(cwd)
8254 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8255 if !visited.insert(canonical.clone()) {
8256 bail!("submodule recursion revisited {}", canonical.display());
8257 }
8258 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8259 return Ok(true);
8260 }
8261 if !unsafe_index_flags(cwd)?.is_empty() {
8262 return Ok(true);
8263 }
8264 if attributes_may_be_modified(cwd)? {
8265 return Ok(true);
8266 }
8267 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8268 return Ok(true);
8269 }
8270 if include_ignored {
8271 let index = index_entries(cwd)?
8272 .into_iter()
8273 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8274 .collect::<BTreeMap<_, _>>();
8275 let head = tree_entries(cwd, "HEAD")?
8276 .into_iter()
8277 .map(|entry| (entry.path, (entry.mode, entry.oid)))
8278 .collect::<BTreeMap<_, _>>();
8279 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8280 return Ok(true);
8281 }
8282 let tracked = tracked_entries(cwd)?;
8283 let effective = check_attributes(cwd, tracked.keys().cloned())?;
8284 let config = CheckoutConfig::read(cwd)?;
8285 for (path, entry) in tracked {
8286 let Some(worktree) = entry.worktree else {
8287 return Ok(true);
8288 };
8289 let attributes = effective.get(&path).ok_or_else(|| {
8290 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8291 })?;
8292 if path_has_ambiguous_transform(&config, attributes)? {
8293 return Ok(true);
8294 }
8295 let symlink_file = entry.index_mode == "120000"
8296 && worktree.mode == "100644"
8297 && worktree.raw_oid == entry.index_oid
8298 && config.symlinks == Some(false);
8299 if worktree.mode != entry.index_mode && !symlink_file {
8300 return Ok(true);
8301 }
8302 if entry.index_mode == "120000" {
8303 if worktree.raw_oid != entry.index_oid {
8304 return Ok(true);
8305 }
8306 continue;
8307 }
8308 #[cfg(unix)]
8309 {
8310 let expected = if entry.index_mode == "100755" {
8311 0o755
8312 } else {
8313 0o644
8314 };
8315 if worktree.permissions != expected {
8316 return Ok(true);
8317 }
8318 }
8319 if allows_expected_crlf(&config, attributes)? {
8320 let (normalized, every_lf_was_crlf) =
8321 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8322 if !every_lf_was_crlf || normalized != entry.index_oid {
8323 return Ok(true);
8324 }
8325 } else if worktree.raw_oid != entry.index_oid {
8326 return Ok(true);
8327 }
8328 }
8329 } else {
8330 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8331 if !run_git_bytes(cwd, &args)?.is_empty() {
8332 return Ok(true);
8333 }
8334 }
8335 for link in gitlinks(cwd)? {
8336 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8337 continue;
8338 };
8339 if include_ignored {
8344 return Ok(true);
8345 }
8346 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8347 if head.trim() != link.oid {
8348 return Ok(true);
8349 }
8350 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8351 {
8352 return Ok(true);
8353 }
8354 }
8355 Ok(false)
8356}
8357
8358pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8359 repository_has_recoverable_work(cwd, false)
8360}
8361
8362fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8363 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8364 Ok(!run_git_bytes(cwd, &args)?.is_empty())
8365}
8366
8367#[cfg(unix)]
8368fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8369 use std::os::unix::ffi::OsStringExt;
8370 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8371}
8372
8373#[cfg(not(unix))]
8374fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8375 String::from_utf8(raw.to_vec())
8376 .map(PathBuf::from)
8377 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8378}
8379
8380fn vanished_untracked_file() -> UntrackedFile {
8396 UntrackedFile {
8397 kind: 0,
8398 len: 0,
8399 modified: None,
8400 created: None,
8401 readonly: false,
8402 symlink_target: None,
8403 #[cfg(unix)]
8404 device: 0,
8405 #[cfg(unix)]
8406 inode: 0,
8407 #[cfg(unix)]
8408 mode: 0,
8409 #[cfg(unix)]
8410 change_seconds: 0,
8411 #[cfg(unix)]
8412 change_nanoseconds: 0,
8413 }
8414}
8415
8416fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8417 let metadata = match std::fs::symlink_metadata(path) {
8418 Ok(metadata) => metadata,
8419 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8420 Err(e) => {
8421 return Err(spar_err!(
8422 "could not inspect the nested repository at {}: {e}",
8423 path.display()
8424 ))
8425 }
8426 };
8427 if !metadata.is_dir() {
8428 bail!(
8429 "git reported {} as a nested repository, but it is not a directory",
8430 path.display()
8431 );
8432 }
8433 if std::fs::symlink_metadata(path.join(".git")).is_err() {
8434 bail!(
8435 "git reported {} as a nested repository, but it has no Git entry",
8436 path.display()
8437 );
8438 }
8439 #[cfg(unix)]
8440 {
8441 use std::os::unix::fs::MetadataExt;
8442 Ok(UntrackedFile {
8443 kind: 3,
8444 len: 0,
8445 modified: None,
8446 created: metadata.created().ok(),
8447 readonly: metadata.permissions().readonly(),
8448 symlink_target: None,
8449 device: metadata.dev(),
8450 inode: metadata.ino(),
8451 mode: metadata.mode(),
8452 change_seconds: 0,
8453 change_nanoseconds: 0,
8454 })
8455 }
8456 #[cfg(not(unix))]
8457 {
8458 Ok(UntrackedFile {
8459 kind: 3,
8460 len: 0,
8461 modified: None,
8462 created: metadata.created().ok(),
8463 readonly: metadata.permissions().readonly(),
8464 symlink_target: None,
8465 })
8466 }
8467}
8468
8469fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8470 let metadata = match std::fs::symlink_metadata(path) {
8471 Ok(metadata) => metadata,
8472 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8473 Err(e) => {
8474 return Err(spar_err!(
8475 "could not inspect untracked file {}: {e}",
8476 path.display()
8477 ))
8478 }
8479 };
8480 let kind = if metadata.file_type().is_symlink() {
8481 2
8482 } else if metadata.is_file() {
8483 1
8484 } else {
8485 bail!(
8486 "untracked path {} is not a regular file or symlink",
8487 path.display()
8488 );
8489 };
8490 let symlink_target = if kind == 2 {
8491 match std::fs::read_link(path) {
8492 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8493 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8494 return Ok(vanished_untracked_file())
8495 }
8496 Err(e) => {
8497 return Err(spar_err!(
8498 "could not read untracked symlink {}: {e}",
8499 path.display()
8500 ))
8501 }
8502 }
8503 } else {
8504 None
8505 };
8506 #[cfg(unix)]
8507 {
8508 use std::os::unix::fs::MetadataExt;
8509 Ok(UntrackedFile {
8510 kind,
8511 len: metadata.len(),
8512 modified: metadata.modified().ok(),
8513 created: metadata.created().ok(),
8514 readonly: metadata.permissions().readonly(),
8515 symlink_target,
8516 device: metadata.dev(),
8517 inode: metadata.ino(),
8518 mode: metadata.mode(),
8519 change_seconds: metadata.ctime(),
8520 change_nanoseconds: metadata.ctime_nsec(),
8521 })
8522 }
8523 #[cfg(not(unix))]
8524 {
8525 Ok(UntrackedFile {
8526 kind,
8527 len: metadata.len(),
8528 modified: metadata.modified().ok(),
8529 created: metadata.created().ok(),
8530 readonly: metadata.permissions().readonly(),
8531 symlink_target,
8532 })
8533 }
8534}
8535
8536#[cfg(unix)]
8537fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8538 use std::os::unix::ffi::OsStrExt;
8539 Ok(value.as_bytes().to_vec())
8540}
8541
8542#[cfg(not(unix))]
8543fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8544 value
8545 .to_str()
8546 .map(|value| value.as_bytes().to_vec())
8547 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8548}
8549
8550#[cfg(unix)]
8551fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8552 use std::os::unix::fs::MetadataExt;
8553 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8554}
8555
8556#[cfg(not(unix))]
8557fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8558 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8559}
8560
8561#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8562pub struct BranchRecord {
8563 pub kind: String,
8564 pub number: i64,
8565}
8566
8567pub fn review_ref(number: i64) -> String {
8570 format!("refs/spar/pr-{number}")
8571}
8572
8573pub fn is_finished(state: &str) -> bool {
8574 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8575}
8576
8577pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8584 if let Some(parent) = path.parent() {
8585 std::fs::create_dir_all(parent)
8586 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8587 }
8588 let tmp = path.with_extension(format!(
8591 "{}.tmp",
8592 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8593 ));
8594 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8595 std::fs::rename(&tmp, path)
8596 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8597 Ok(())
8598}
8599
8600pub fn has_heading(text: &str, title: &str) -> bool {
8607 let wanted = title.trim().to_lowercase();
8608 if wanted.is_empty() {
8609 return false;
8610 }
8611 text.lines()
8612 .filter_map(|line| line.trim().strip_prefix("## "))
8613 .any(|heading| heading.trim().to_lowercase() == wanted)
8614}
8615
8616pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8619 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8620}
8621
8622#[derive(Deserialize)]
8624#[serde(rename_all = "camelCase")]
8625pub struct LinkedPrRow {
8626 number: i64,
8627 #[serde(default)]
8628 url: String,
8629 #[serde(default)]
8630 title: String,
8631 #[serde(default)]
8632 closing_issues_references: Vec<IssueRef>,
8633}
8634
8635pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8642 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8643}
8644
8645pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8647 rows.iter()
8648 .find(|row| {
8649 row.closing_issues_references
8650 .iter()
8651 .any(|linked| linked.number == issue)
8652 })
8653 .map(|row| PrRef {
8654 number: row.number,
8655 url: row.url.clone(),
8656 title: row.title.clone(),
8657 })
8658}
8659
8660pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8662 pick_linked_pr(&linked_pr_rows(json)?, issue)
8663}
8664
8665pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8672 if text.trim().is_empty() {
8673 return Err(spar_err!("GitHub returned no comment data"));
8674 }
8675 let mut out = Vec::new();
8676 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8677 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8678 Value::Array(items) => out.extend(items),
8679 _ => return Err(spar_err!("unexpected non-array comment page")),
8680 }
8681 }
8682 Ok(out)
8683}
8684
8685pub fn parse_comment_pages(text: &str) -> Vec<Value> {
8686 let mut out = Vec::new();
8687 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8688 match value {
8689 Ok(Value::Array(items)) => out.extend(items),
8690 Ok(other) => out.push(other),
8691 Err(_) => break,
8692 }
8693 }
8694 out
8695}
8696
8697pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
8705 body.trim_start().starts_with(STATE_MARKER)
8706 && !viewer.trim().is_empty()
8707 && author.trim().eq_ignore_ascii_case(viewer.trim())
8708 && parse_state_comment(body).is_some()
8709}
8710
8711pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
8714 let marker = body.find(STATE_MARKER)?;
8715 let start = body[marker..].find('{')? + marker;
8716 let end = body.rfind('}')?;
8717 if end <= start {
8718 return None;
8719 }
8720 match serde_json::from_str(&body[start..=end]) {
8721 Ok(state) => Some(state),
8722 Err(_) => {
8723 logdim!("found a spar state comment but could not parse it");
8724 None
8725 }
8726 }
8727}
8728
8729fn choose_state_for_head(
8730 candidates: Vec<PersistedState>,
8731 actual_head: &str,
8732) -> Option<PersistedState> {
8733 let matching: Vec<PersistedState> = candidates
8734 .iter()
8735 .filter(|state| state.pr_head == actual_head)
8736 .cloned()
8737 .collect();
8738 if !matching.is_empty() {
8739 return newest_state(matching);
8740 }
8741 newest_state(candidates)
8742}
8743
8744fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
8745 candidates.into_iter().reduce(|best, candidate| {
8746 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
8747 candidate
8748 } else {
8749 best
8754 }
8755 })
8756}
8757
8758pub fn self_binary() -> Result<PathBuf> {
8764 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
8765 let path = PathBuf::from(path);
8766 if proc::is_executable(&path) {
8767 return Ok(path);
8768 }
8769 bail!(
8770 "SPAR_SELF_BIN is set to {}, which is not executable",
8771 path.display()
8772 );
8773 }
8774 std::env::current_exe()
8775 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8776}
8777
8778fn bool_env(value: bool) -> &'static str {
8779 if value {
8780 "1"
8781 } else {
8782 "0"
8783 }
8784}
8785
8786pub fn sh_quote(text: &str) -> String {
8789 format!("'{}'", text.replace('\'', r"'\''"))
8790}
8791
8792pub fn style_from_env() -> Style {
8795 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8796 Style {
8797 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8798 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8799 ..Style::permissive()
8800 }
8801}
8802
8803#[cfg(test)]
8804mod tests {
8805 use super::*;
8806 use crate::config::StateStore;
8807 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8808 use std::process::Command;
8809
8810 fn repo_for_titles() -> Repo {
8811 Repo {
8812 root: PathBuf::from("/nonexistent"),
8813 style: Style::default(),
8814 branch_prefix: String::new(),
8815 state_store: StateStore::Local,
8816 followups: crate::config::Followups::Issues,
8817 drafts: Drafts::Never,
8818 fork_when_denied: true,
8819 fork: OnceLock::new(),
8820 viewer: OnceLock::new(),
8821 checkpoints: Mutex::new(BTreeMap::new()),
8822 writes: WriteStats::default(),
8823 }
8824 }
8825
8826 #[test]
8827 fn write_results_accumulate_for_the_run() {
8828 let repo = repo_for_titles();
8829
8830 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8831 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8832
8833 assert_eq!(
8834 WriteSummary {
8835 attempted: 2,
8836 failed: 1,
8837 },
8838 repo.write_summary()
8839 );
8840 }
8841
8842 #[test]
8843 fn only_failed_write_preflights_join_the_summary() {
8844 let repo = repo_for_titles();
8845
8846 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8847 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8848
8849 assert_eq!(
8850 WriteSummary {
8851 attempted: 1,
8852 failed: 1,
8853 },
8854 repo.write_summary()
8855 );
8856 }
8857
8858 #[test]
8859 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8860 let repo = repo_for_titles();
8861
8862 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8863 assert_eq!(
8864 WriteSummary {
8865 attempted: 1,
8866 failed: 1,
8867 },
8868 repo.write_summary()
8869 );
8870 }
8871
8872 #[test]
8873 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8874 let mut repo = repo_for_titles();
8875 repo.followups = Followups::Local;
8876
8877 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8878 assert_eq!(WriteSummary::default(), repo.write_summary());
8879 }
8880
8881 #[test]
8882 fn a_failed_remote_state_read_stops_before_state_mutation() {
8883 let root = std::env::temp_dir().join(format!(
8884 "spar-state-preflight-{}-{}",
8885 std::process::id(),
8886 std::time::SystemTime::now()
8887 .duration_since(std::time::UNIX_EPOCH)
8888 .unwrap()
8889 .as_nanos()
8890 ));
8891 std::fs::create_dir_all(&root).unwrap();
8892 let _fixture = ReviewFixture { root: root.clone() };
8893 let mut repo = repo_for_titles();
8894 repo.root = root;
8895 repo.state_store = StateStore::Both;
8896 let state = PersistedState {
8897 version: 1,
8898 checkpoint: 4,
8899 round: 2,
8900 next_actor: "a".into(),
8901 status: Status::Pending,
8902 pr_head: "abc123".into(),
8903 ledger: Ledger::new(),
8904 filed: Vec::new(),
8905 open_findings: Vec::new(),
8906 disputes: Vec::new(),
8907 noted: Vec::new(),
8908 };
8909
8910 let error = repo
8911 .write_state_after_remote_read(
8912 7,
8913 &state,
8914 Err(crate::error::SparError::new("state comments unavailable")),
8915 )
8916 .unwrap_err();
8917
8918 assert!(error.to_string().contains("state comments unavailable"));
8919 assert!(!repo.state_path(7).exists());
8920 assert_eq!(0, repo.remembered_checkpoint(7));
8921 assert_eq!(
8922 WriteSummary {
8923 attempted: 1,
8924 failed: 1,
8925 },
8926 repo.write_summary()
8927 );
8928 }
8929
8930 #[test]
8931 fn only_known_build_and_cache_directories_are_generated_artifacts() {
8932 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
8933 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
8934 assert!(is_generated_artifact(Path::new(
8935 "package/node_modules/dependency/file.js"
8936 )));
8937 assert!(!is_generated_artifact(Path::new(
8938 "distribution/required-package.js"
8939 )));
8940 assert!(!is_generated_artifact(Path::new(
8941 "generated/required-fixture.txt"
8942 )));
8943 assert!(!is_generated_artifact(Path::new("local.env")));
8944 }
8945
8946 #[test]
8947 fn only_finder_state_files_are_desktop_metadata() {
8948 assert!(is_desktop_metadata(Path::new(".DS_Store")));
8949 assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
8950 assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
8951 assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
8952 }
8953
8954 struct ReviewFixture {
8955 root: PathBuf,
8956 }
8957
8958 impl Drop for ReviewFixture {
8959 fn drop(&mut self) {
8960 let _ = std::fs::remove_dir_all(&self.root);
8961 }
8962 }
8963
8964 fn test_git(cwd: &Path, args: &[&str]) -> String {
8965 let output = Command::new("git")
8966 .args(args)
8967 .current_dir(cwd)
8968 .output()
8969 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
8970 assert!(
8971 output.status.success(),
8972 "git {args:?} failed: {}",
8973 String::from_utf8_lossy(&output.stderr)
8974 );
8975 String::from_utf8_lossy(&output.stdout).into_owned()
8976 }
8977
8978 #[test]
8982 fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
8983 let root =
8984 std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
8985 let _cleanup = ReviewFixture { root: root.clone() };
8986 let origin = root.join("origin.git");
8987 let work = root.join("work");
8988 let marker = root.join("textconv-ran");
8989 std::fs::create_dir_all(&origin).unwrap();
8990 std::fs::create_dir_all(&work).unwrap();
8991 test_git(&origin, &["init", "--bare", "-b", "main"]);
8992 test_git(&work, &["init", "-b", "main"]);
8993 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
8994 test_git(&work, &["config", "user.name", "spar test"]);
8995 test_git(&work, &["config", "commit.gpgsign", "false"]);
8996 test_git(&work, &["config", "diff.noprefix", "true"]);
8997 test_git(&work, &["config", "diff.context", "0"]);
8998 test_git(&work, &["config", "diff.interHunkContext", "10"]);
8999 test_git(&work, &["config", "diff.algorithm", "patience"]);
9000 test_git(&work, &["config", "color.diff", "always"]);
9001 test_git(
9002 &work,
9003 &[
9004 "config",
9005 "diff.conv.textconv",
9006 &format!("touch {}; cat", marker.display()),
9007 ],
9008 );
9009 std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9010 let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9011 std::fs::write(work.join("src.txt"), &lines).unwrap();
9012 test_git(&work, &["add", "."]);
9013 test_git(&work, &["commit", "-m", "seed"]);
9014 test_git(
9015 &work,
9016 &["remote", "add", "origin", origin.to_str().unwrap()],
9017 );
9018 test_git(&work, &["push", "-u", "origin", "main"]);
9019 test_git(&work, &["checkout", "-b", "feature"]);
9020 std::fs::write(
9021 work.join("src.txt"),
9022 lines
9023 .replace("line 5\n", "changed 5\n")
9024 .replace("line 15\n", "changed 15\n"),
9025 )
9026 .unwrap();
9027 std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9028 std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9029 test_git(&work, &["add", "."]);
9030 test_git(&work, &["commit", "-m", "change"]);
9031 let cfg = crate::config::parse(
9032 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9033 )
9034 .unwrap();
9035 let repo = Repo::open(&work, &cfg).unwrap();
9036 assert_eq!(
9037 Hunks::from([
9038 ("new.txt".to_string(), vec![(1, 2)]),
9039 ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9040 ("with space.txt".to_string(), vec![(1, 1)]),
9041 ]),
9042 repo.diff_hunks(&work, "main")
9043 );
9044 assert!(!marker.exists(), "the text converter ran");
9045 test_git(&work, &["diff", "origin/main...HEAD"]);
9047 assert!(marker.exists(), "the fixture's converter never runs at all");
9048 }
9049
9050 fn review_fixture(
9051 tag: &str,
9052 number: i64,
9053 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9054 use std::sync::atomic::{AtomicU32, Ordering};
9055 static NEXT: AtomicU32 = AtomicU32::new(0);
9056 let id = NEXT.fetch_add(1, Ordering::Relaxed);
9057 let root =
9058 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9059 let origin = root.join("origin.git");
9060 let work = root.join("work");
9061 std::fs::create_dir_all(&origin).unwrap();
9062 std::fs::create_dir_all(&work).unwrap();
9063 test_git(&origin, &["init", "--bare", "-b", "main"]);
9064 test_git(&work, &["init", "-b", "main"]);
9065 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9066 test_git(&work, &["config", "user.name", "spar test"]);
9067 test_git(&work, &["config", "commit.gpgsign", "false"]);
9068 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9069 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9070 std::fs::write(work.join("README.md"), "seed\n").unwrap();
9071 std::fs::write(work.join("data.txt"), "old\n").unwrap();
9072 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9073 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9074 test_git(&work, &["add", "."]);
9075 test_git(&work, &["commit", "-m", "seed"]);
9076 test_git(
9077 &work,
9078 &["remote", "add", "origin", origin.to_str().unwrap()],
9079 );
9080 test_git(&work, &["push", "-u", "origin", "main"]);
9081 test_git(
9082 &work,
9083 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9084 );
9085 let cfg = crate::config::parse(
9086 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9087 )
9088 .unwrap();
9089 let repo = Repo::open(&work, &cfg).unwrap();
9090 let path = repo.worktree_for_pr_head(number).unwrap();
9091 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9092 (ReviewFixture { root }, repo, path, checkpoint)
9093 }
9094
9095 #[test]
9096 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9097 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9098
9099 repo.release_review_worktree_checked(901, &checkpoint)
9100 .unwrap();
9101
9102 assert!(!path.exists());
9103 }
9104
9105 #[test]
9106 fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9107 let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9108 let work = fixture.root.join("work");
9109 let seed = test_git(&work, &["rev-parse", "HEAD"]);
9110 let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9111 let publish = |message: &str| {
9114 let oid = test_git(
9115 &work,
9116 &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9117 );
9118 let oid = oid.trim().to_string();
9119 test_git(
9120 &work,
9121 &[
9122 "push",
9123 "--force",
9124 "origin",
9125 &format!("{oid}:refs/pull/923/head"),
9126 ],
9127 );
9128 oid
9129 };
9130 let reviewed = publish("reviewed head");
9131 repo.worktree_for_pr_head(923).unwrap();
9132 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9133
9134 let rewritten = publish("rewritten head");
9135 repo.worktree_for_pr_head(923).unwrap();
9136
9137 assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9138 }
9139
9140 #[test]
9141 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9142 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9143 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9144 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9145 test_git(&path, &["add", "recovery.txt"]);
9146 test_git(&path, &["commit", "-m", "recovery commit"]);
9147 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9148 test_git(&path, &["reset", "--hard", "main"]);
9149
9150 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9151 test_git(
9152 &path,
9153 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9154 );
9155 }
9156
9157 #[test]
9158 fn a_review_ref_reflog_only_commit_prevents_deletion() {
9159 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9160 let local_ref = review_ref(921);
9161 let original = test_git(&path, &["rev-parse", &local_ref]);
9162 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9163 let recovery = test_git(
9164 &path,
9165 &[
9166 "commit-tree",
9167 tree.trim(),
9168 "-p",
9169 original.trim(),
9170 "-m",
9171 "review ref recovery",
9172 ],
9173 );
9174 test_git(
9175 &path,
9176 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9177 );
9178 test_git(
9179 &path,
9180 &["update-ref", &local_ref, original.trim(), recovery.trim()],
9181 );
9182
9183 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9184 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9185 test_git(
9186 &path,
9187 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9188 );
9189 }
9190
9191 #[test]
9192 fn an_unpublished_commit_message_draft_is_recoverable() {
9193 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9194 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9195 let git_dir = if raw.is_absolute() {
9196 raw
9197 } else {
9198 path.join(raw)
9199 };
9200 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9201
9202 assert!(repository_has_recoverable_work(&path, true).unwrap());
9203 assert_eq!(
9204 "unique recovery draft\n",
9205 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9206 );
9207 }
9208
9209 #[test]
9210 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9211 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9212 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9213
9214 let error = repo
9215 .release_review_worktree_checked(902, &checkpoint)
9216 .unwrap_err();
9217
9218 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9219 assert!(error.to_string().contains("kept for recovery"), "{error}");
9220 assert_eq!(
9221 "recover me\n",
9222 std::fs::read_to_string(path.join("README.md")).unwrap()
9223 );
9224 repo.release_review_worktree(902);
9225 }
9226
9227 #[test]
9228 fn a_review_commit_is_retained_after_a_checked_read() {
9229 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9230 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9231 test_git(&path, &["add", "review-note.txt"]);
9232 test_git(&path, &["commit", "-m", "local review recovery"]);
9233 let head = test_git(&path, &["rev-parse", "HEAD"]);
9234
9235 let error = repo
9236 .release_review_worktree_checked(903, &checkpoint)
9237 .unwrap_err();
9238
9239 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9240 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9241 assert_eq!(
9242 "recover me\n",
9243 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9244 );
9245 repo.release_review_worktree(903);
9246 }
9247
9248 #[test]
9252 fn an_ignored_review_file_is_retained_after_a_checked_read() {
9253 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9254 std::fs::create_dir_all(path.join("generated")).unwrap();
9255 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9256
9257 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9258 .expect("its own output is not a change");
9259 let error = repo
9260 .release_review_worktree_checked(904, &checkpoint)
9261 .unwrap_err();
9262
9263 assert!(error.to_string().contains("recoverable"), "{error}");
9264 assert_eq!(
9265 "recover me\n",
9266 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9267 );
9268 repo.release_review_worktree(904);
9269 }
9270
9271 #[test]
9272 fn a_preexisting_ignored_review_file_change_is_retained() {
9278 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9279 std::fs::create_dir_all(path.join("generated")).unwrap();
9280 let ignored = path.join("generated/recovery.txt");
9281 std::fs::write(&ignored, "before\n").unwrap();
9282 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9283 std::fs::write(&ignored, "after!\n").unwrap();
9284
9285 let error = repo
9286 .release_review_worktree_checked(905, &checkpoint)
9287 .unwrap_err();
9288
9289 assert!(error.to_string().contains("recoverable"), "{error}");
9290 assert!(path.exists());
9291 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9292 repo.release_review_worktree(905);
9293 }
9294
9295 #[test]
9296 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9297 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9298 std::fs::create_dir_all(path.join("generated")).unwrap();
9299 let ignored = path.join("generated/recovery.txt");
9300 std::fs::write(&ignored, "keep me\n").unwrap();
9301 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9302
9303 let error = repo
9304 .release_review_worktree_checked(906, &checkpoint)
9305 .unwrap_err();
9306
9307 assert!(error.to_string().contains("recoverable"), "{error}");
9308 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9309 }
9310
9311 #[test]
9312 fn overwriting_a_preexisting_untracked_file_is_detected() {
9313 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9314 let untracked = path.join("notes.txt");
9315 std::fs::write(&untracked, "before\n").unwrap();
9316 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9317 std::fs::write(&untracked, "after!\n").unwrap();
9318
9319 let error = repo
9320 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9321 .unwrap_err();
9322
9323 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9324 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9325 }
9326
9327 #[test]
9328 fn an_assume_unchanged_edit_is_detected() {
9329 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9330 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9331 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9332
9333 let error = repo
9334 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9335 .unwrap_err();
9336
9337 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9338 assert_eq!(
9339 "hidden\n",
9340 std::fs::read_to_string(path.join("README.md")).unwrap()
9341 );
9342 }
9343
9344 #[test]
9345 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9346 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9347 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9348 test_git(&path, &["add", "README.md"]);
9349 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9350
9351 let error = repo
9352 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9353 .unwrap_err();
9354
9355 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9356 assert_eq!(
9357 b"seed\r\n",
9358 std::fs::read(path.join("README.md")).unwrap().as_slice()
9359 );
9360 }
9361
9362 #[cfg(unix)]
9363 #[test]
9364 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9365 use std::os::unix::fs::PermissionsExt;
9366
9367 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9368 test_git(&path, &["config", "core.filemode", "false"]);
9369 let readme = path.join("README.md");
9370 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9371 permissions.set_mode(0o755);
9372 std::fs::set_permissions(&readme, permissions).unwrap();
9373 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9374
9375 let error = repo
9376 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9377 .unwrap_err();
9378
9379 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9380 assert_eq!(
9381 0o755,
9382 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9383 );
9384 }
9385
9386 #[test]
9387 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9388 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9389 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9390 test_git(&path, &["add", ".gitattributes"]);
9391 test_git(&path, &["commit", "-m", "select data filter"]);
9392 let baseline = repo.worktree_baseline(&path).unwrap();
9393 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9394
9395 assert!(repo
9396 .commit_pending_changes(&path, &baseline, "change data", "change data")
9397 .unwrap());
9398 let error = repo
9399 .refuse_unrepresented_tracked_changes(&path, &baseline)
9400 .unwrap_err();
9401
9402 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9403 assert_eq!(
9404 "secret: recover me\nnew\n",
9405 std::fs::read_to_string(path.join("data.txt")).unwrap()
9406 );
9407 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9408 }
9409
9410 #[test]
9411 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9412 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9413 std::fs::create_dir_all(path.join("target")).unwrap();
9414 let untracked = path.join("target/user.yaml");
9415 std::fs::write(&untracked, "user data\n").unwrap();
9416 let baseline = repo.worktree_baseline(&path).unwrap();
9417 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9418
9419 assert!(repo
9420 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9421 .unwrap());
9422
9423 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9424 assert_eq!(
9425 "?? target/user.yaml\n",
9426 test_git(&path, &["status", "--short", "--untracked-files=all"])
9427 );
9428 assert!(test_git(
9429 &path,
9430 &[
9431 "ls-tree",
9432 "-r",
9433 "--name-only",
9434 "HEAD",
9435 "--",
9436 "target/user.yaml"
9437 ]
9438 )
9439 .is_empty());
9440 }
9441
9442 #[test]
9443 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9444 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9445 std::fs::create_dir_all(path.join("target")).unwrap();
9446 let untracked = path.join("target/user.yaml");
9447 std::fs::write(&untracked, "before\n").unwrap();
9448 let baseline = repo.worktree_baseline(&path).unwrap();
9449 let before = test_git(&path, &["rev-parse", "HEAD"]);
9450 std::fs::write(&untracked, "after\n").unwrap();
9451 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9452
9453 let error = repo
9454 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9455 .unwrap_err();
9456
9457 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9458 assert!(error.to_string().contains("target/user.yaml"), "{error}");
9459 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9460 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9461 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9462 }
9463
9464 #[test]
9465 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9466 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9467 let baseline = repo.worktree_baseline(&path).unwrap();
9468 std::fs::create_dir_all(path.join("target")).unwrap();
9469 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9470
9471 assert!(repo
9472 .commit_pending_changes(&path, &baseline, "add file", "add file")
9473 .unwrap());
9474
9475 assert_eq!(
9476 "new file\n",
9477 test_git(&path, &["show", "HEAD:target/new.txt"])
9478 );
9479 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9480 }
9481
9482 #[test]
9483 fn a_managed_commit_accepts_a_custom_output_rebuild() {
9484 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9485 exclude_paths(&repo, &["manager/public/"]);
9486 let output = path.join("manager/public/assets");
9487 std::fs::create_dir_all(&output).unwrap();
9488 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9489 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9490 let baseline = repo.worktree_baseline(&path).unwrap();
9491 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9492 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9493 std::fs::remove_file(output.join("old.js")).unwrap();
9494 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9495 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9496
9497 assert!(repo
9498 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9499 .unwrap());
9500 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9501 repo.refuse_changed_existing_untracked(&path, &baseline)
9502 .unwrap();
9503 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9504 .unwrap();
9505
9506 assert_eq!(
9507 "tracked change\n",
9508 test_git(&path, &["show", "HEAD:README.md"])
9509 );
9510 assert_eq!(
9511 "new source\n",
9512 test_git(&path, &["show", "HEAD:new-source.txt"])
9513 );
9514 assert!(test_git(
9515 &path,
9516 &[
9517 "ls-tree",
9518 "-r",
9519 "--name-only",
9520 "HEAD",
9521 "--",
9522 "manager/public"
9523 ]
9524 )
9525 .is_empty());
9526 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9527 assert!(!output.join("old.js").exists());
9528 assert_eq!(
9529 "new styles\n",
9530 std::fs::read_to_string(output.join("index.css")).unwrap()
9531 );
9532 assert!(repository_has_recoverable_work(&path, true).unwrap());
9533 repo.release_review_worktree(912);
9534 assert!(output.join("index-DUym-Rfj.js").exists());
9535 }
9536
9537 #[test]
9538 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9539 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9540 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9541 let baseline = repo.worktree_baseline(&path).unwrap();
9542 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9543 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9544
9545 let error = repo
9546 .commit_pending_changes(&path, &baseline, "change", "change")
9547 .unwrap_err();
9548
9549 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9550 assert!(error.to_string().contains("notes.txt"), "{error}");
9551 }
9552
9553 #[test]
9554 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9555 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9556 let baseline = repo.worktree_baseline(&path).unwrap();
9557 let nested = path.join("generated/repro");
9558 std::fs::create_dir_all(&nested).unwrap();
9559 test_git(&nested, &["init"]);
9560 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9561
9562 assert!(repo
9563 .commit_pending_changes(&path, &baseline, "change", "change")
9564 .unwrap());
9565 assert!(!repo.has_uncommitted_changes(&path).unwrap());
9566 assert!(repository_has_recoverable_work(&path, true).unwrap());
9567 repo.release_review_worktree(927);
9568 assert!(nested.join(".git").exists());
9569 }
9570
9571 #[test]
9572 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9573 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9574 test_git(&path, &["config", "core.autocrlf", "true"]);
9575 std::fs::write(path.join("README.md"), "seed\n").unwrap();
9576 assert_eq!(
9577 test_git(&path, &["hash-object", "README.md"]).trim(),
9578 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9579 );
9580
9581 assert!(repository_has_recoverable_work(&path, true).unwrap());
9582 assert_eq!(
9583 "seed\n",
9584 std::fs::read_to_string(path.join("README.md")).unwrap()
9585 );
9586 }
9587
9588 #[test]
9589 fn autocrlf_input_overrides_a_crlf_core_eol() {
9590 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9591 test_git(&path, &["config", "core.autocrlf", "input"]);
9592 test_git(&path, &["config", "core.eol", "crlf"]);
9593 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9594 assert_eq!(
9595 test_git(&path, &["hash-object", "README.md"]).trim(),
9596 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9597 );
9598
9599 assert!(repository_has_recoverable_work(&path, true).unwrap());
9600 assert_eq!(
9601 b"seed\r\n",
9602 std::fs::read(path.join("README.md")).unwrap().as_slice()
9603 );
9604 }
9605
9606 #[cfg(unix)]
9607 #[test]
9608 fn a_non_executable_permission_change_is_recoverable() {
9609 use std::os::unix::fs::PermissionsExt;
9610
9611 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9612 let readme = path.join("README.md");
9613 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9614 permissions.set_mode(0o600);
9615 std::fs::set_permissions(&readme, permissions).unwrap();
9616 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9617
9618 let error = repo
9619 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9620 .unwrap_err();
9621
9622 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9623 assert!(repository_has_recoverable_work(&path, true).unwrap());
9624 assert_eq!(
9625 0o600,
9626 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9627 );
9628 }
9629
9630 #[cfg(unix)]
9631 #[test]
9632 fn a_managed_commit_skips_signing_and_hooks() {
9633 use std::os::unix::fs::PermissionsExt;
9634
9635 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9636 let common = common_git_dir(&path).unwrap();
9637 let hook = common.join("hooks/pre-commit");
9638 let marker = fixture.root.join("hook-ran");
9639 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9640 std::fs::write(
9641 &hook,
9642 format!(
9643 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9644 sh_quote(marker.to_str().unwrap())
9645 ),
9646 )
9647 .unwrap();
9648 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9649 permissions.set_mode(0o755);
9650 std::fs::set_permissions(&hook, permissions).unwrap();
9651 test_git(&path, &["config", "commit.gpgsign", "true"]);
9652 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9653 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9654 test_git(&path, &["add", "managed.txt"]);
9655
9656 repo.commit_staged_changes(&path, "record managed change")
9657 .unwrap();
9658
9659 assert!(!marker.exists());
9660 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9661 }
9662
9663 #[test]
9664 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9665 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9666 std::fs::write(
9667 path.join(".gitattributes"),
9668 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
9669 )
9670 .unwrap();
9671 test_git(&path, &["add", ".gitattributes"]);
9672 test_git(&path, &["commit", "-m", "select automatic text"]);
9673 test_git(&path, &["config", "core.autocrlf", "true"]);
9674
9675 assert!(repository_has_recoverable_work(&path, true).unwrap());
9676 }
9677
9678 #[test]
9679 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
9680 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
9681 std::fs::write(
9682 path.join(".gitattributes"),
9683 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
9684 )
9685 .unwrap();
9686 test_git(&path, &["add", ".gitattributes"]);
9687 test_git(&path, &["commit", "-m", "select ident expansion"]);
9688 std::fs::write(path.join("README.md"), "seed\n").unwrap();
9689
9690 assert!(repository_has_recoverable_work(&path, true).unwrap());
9691 }
9692
9693 #[test]
9699 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
9700 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
9701 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
9702 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9703 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
9704 std::fs::write(
9705 path.join("bindings/uniffi-bindgen/Cargo.lock"),
9706 "# generated\n",
9707 )
9708 .unwrap();
9709
9710 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9711 .expect("a path the project ignores is the call's own output");
9712 }
9713
9714 #[test]
9715 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
9716 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
9717 exclude_paths(&repo, &["target/"]);
9718 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9719 std::fs::create_dir_all(path.join("target/package")).unwrap();
9720 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
9721
9722 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9723 .expect("an ignored attribute file governs only ignored paths");
9724 assert!(
9725 !repo.has_uncommitted_changes(&path).unwrap(),
9726 "ignored dependency attributes must not stop the next review"
9727 );
9728 }
9729
9730 #[test]
9731 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
9732 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
9733 std::fs::create_dir_all(path.join("source")).unwrap();
9734 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
9735
9736 assert!(repo.has_uncommitted_changes(&path).unwrap());
9737 }
9738
9739 #[test]
9740 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
9741 for source in ["repository", "nested", "local", "global"] {
9742 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
9743 std::fs::create_dir_all(path.join("manager")).unwrap();
9744 match source {
9745 "repository" => {
9746 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
9747 .unwrap();
9748 test_git(&path, &["add", ".gitignore"]);
9749 test_git(&path, &["commit", "-m", "ignore artifacts"]);
9750 }
9751 "nested" => {
9752 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
9753 test_git(&path, &["add", "manager/.gitignore"]);
9754 test_git(&path, &["commit", "-m", "ignore artifacts"]);
9755 }
9756 "local" => exclude_paths(&repo, &["manager/scratch/"]),
9757 "global" => {
9758 let excludes = repo.root().parent().unwrap().join("global-ignore");
9759 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
9760 test_git(
9761 &path,
9762 &["config", "core.excludesFile", excludes.to_str().unwrap()],
9763 );
9764 }
9765 _ => unreachable!(),
9766 }
9767 let output = path.join("manager/scratch");
9768 std::fs::create_dir_all(&output).unwrap();
9769 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
9770 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
9771 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9772 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
9773 std::fs::remove_file(output.join(".DS_Store")).unwrap();
9774 for file in [
9775 ".gitattributes",
9776 "Cargo.lock",
9777 "font.woff2",
9778 "bundle.js",
9779 "cache.db",
9780 ] {
9781 std::fs::write(output.join(file), "generated\n").unwrap();
9782 }
9783 #[cfg(unix)]
9784 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
9785 let nested = output.join("dependency");
9786 std::fs::create_dir_all(&nested).unwrap();
9787 test_git(&nested, &["init"]);
9788 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9789 .unwrap_or_else(|error| panic!("{source}: {error}"));
9790 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9791
9792 let baseline = repo.worktree_baseline(&path).unwrap();
9793 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
9794 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
9795 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
9796 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
9797 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
9798 assert!(repo
9799 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
9800 .unwrap_or_else(|error| panic!("{source}: {error}")));
9801 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9802 assert!(test_git(
9803 &path,
9804 &[
9805 "ls-tree",
9806 "-r",
9807 "--name-only",
9808 "HEAD",
9809 "--",
9810 "manager/scratch"
9811 ]
9812 )
9813 .is_empty());
9814 assert!(
9815 repository_has_recoverable_work(&path, true).unwrap(),
9816 "{source}"
9817 );
9818 repo.release_review_worktree(945);
9819 assert_eq!(
9820 "LOCAL=updated\n",
9821 std::fs::read_to_string(output.join(".env.local")).unwrap()
9822 );
9823 assert!(nested.join(".git").exists(), "{source}");
9824 }
9825 }
9826
9827 #[test]
9831 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
9832 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
9833 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9834 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
9835
9836 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9837 .expect("spar's own marker is not something a call did");
9838 }
9839
9840 #[test]
9841 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
9842 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
9843
9844 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9845 .expect("a path a build removed is not an unreadable worktree");
9846
9847 assert_eq!(vanished_untracked_file(), gone);
9848 }
9849
9850 #[test]
9853 fn a_vanished_path_is_not_recorded_as_absent() {
9854 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9855 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9856 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9857 std::fs::remove_file(path.join("notes.txt")).unwrap();
9858
9859 let error = repo
9860 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9861 .unwrap_err();
9862
9863 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9864 }
9865
9866 fn exclude_paths(repo: &Repo, lines: &[&str]) {
9867 use std::io::Write;
9868 let path = repo.root().join(".git").join("info").join("exclude");
9869 let mut file = std::fs::OpenOptions::new()
9870 .create(true)
9871 .append(true)
9872 .open(&path)
9873 .unwrap();
9874 for line in lines {
9875 writeln!(file, "{line}").unwrap();
9876 }
9877 }
9878
9879 #[test]
9880 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9881 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9882 exclude_paths(&repo, &["dist/"]);
9883 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9884 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9885 let baseline = repo.worktree_baseline(&path).unwrap();
9886 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9887 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9888 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9889 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9890
9891 assert!(repo
9892 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9893 .unwrap());
9894 assert_eq!(
9895 "tracked change\n",
9896 std::fs::read_to_string(path.join("README.md")).unwrap()
9897 );
9898 }
9899
9900 #[test]
9901 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
9902 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
9903 exclude_paths(&repo, &["dist/"]);
9904 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9905 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9906 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
9907 let baseline = repo.worktree_baseline(&path).unwrap();
9908 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9909 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
9910 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9911 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9912
9913 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9914 repo.refuse_changed_existing_untracked(&path, &baseline)
9915 .unwrap();
9916 }
9917
9918 #[test]
9919 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
9920 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
9921 exclude_paths(&repo, &["dist/"]);
9922 std::fs::create_dir_all(path.join("dist")).unwrap();
9923 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9924 let baseline = repo.worktree_baseline(&path).unwrap();
9925 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9926 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9927
9928 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9929 repo.refuse_changed_existing_untracked(&path, &baseline)
9930 .unwrap();
9931 }
9932
9933 #[test]
9942 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
9943 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
9944 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
9945 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
9946 test_git(
9947 repo.root(),
9948 &[
9949 "worktree",
9950 "add",
9951 "-b",
9952 "issue-950",
9953 held.to_str().unwrap(),
9954 "main",
9955 ],
9956 );
9957
9958 let found = repo
9959 .worktree_holding("issue-950")
9960 .expect("the branch is checked out somewhere");
9961
9962 assert!(found.ends_with("issue-950"), "{}", found.display());
9963 assert_eq!(None, repo.worktree_holding("issue-951"));
9964 }
9965
9966 #[test]
9967 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
9968 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
9969 exclude_paths(&repo, &["manager/public/assets/"]);
9970 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
9971 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
9972 std::fs::write(&bundle, "first build\n").unwrap();
9973 let baseline = repo.worktree_baseline(&path).unwrap();
9974 std::fs::write(&bundle, "second build\n").unwrap();
9975 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
9976
9977 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
9978 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9979 }
9980
9981 #[test]
9982 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
9983 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
9984 exclude_paths(&repo, &["dist/"]);
9985 let baseline = repo.worktree_baseline(&path).unwrap();
9986 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
9987
9988 let error = repo
9989 .refuse_read_only_leavings(&path, &baseline)
9990 .unwrap_err();
9991
9992 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9993 assert!(error.to_string().contains("scratch.md"), "{error}");
9994 }
9995
9996 #[test]
9997 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
9998 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
9999 exclude_paths(&repo, &["dist/", ".env.local"]);
10000 std::fs::create_dir_all(path.join("dist")).unwrap();
10001 let baseline = repo.worktree_baseline(&path).unwrap();
10002 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10003 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10004
10005 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10006 assert!(repository_has_recoverable_work(&path, true).unwrap());
10007 }
10008
10009 #[test]
10010 fn a_read_only_inspection_may_rebuild_generated_output() {
10011 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10012 exclude_paths(&repo, &["dist/"]);
10013 std::fs::create_dir_all(path.join("dist")).unwrap();
10014 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10015 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10016 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10017 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10018
10019 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10020 .unwrap();
10021 }
10022
10023 #[test]
10024 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10025 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10026 exclude_paths(&repo, &["dist/", ".env.local"]);
10027 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10028 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10029 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10030
10031 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10032 .unwrap();
10033 }
10034
10035 #[test]
10036 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10037 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10038 exclude_paths(&repo, &["dist/", ".env.local"]);
10039 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10040 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10041 std::fs::remove_file(path.join(".env.local")).unwrap();
10042
10043 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10044 .unwrap();
10045 }
10046
10047 #[test]
10048 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10049 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10050 exclude_paths(&repo, &["dist/"]);
10051 std::fs::write(path.join("notes.md"), "before\n").unwrap();
10052 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10053 std::fs::write(path.join("notes.md"), "after\n").unwrap();
10054
10055 let error = repo
10056 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10057 .unwrap_err();
10058
10059 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10060 assert!(error.to_string().contains("notes.md"), "{error}");
10061 }
10062
10063 #[test]
10064 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10065 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10066 exclude_paths(&repo, &["dist/", "local.env"]);
10067 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10068 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10069 std::fs::write(
10072 repo.root().join(".git").join("info").join("exclude"),
10073 "dist/\n",
10074 )
10075 .unwrap();
10076
10077 let error = repo
10078 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10079 .unwrap_err();
10080
10081 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10082 assert!(error.to_string().contains("local.env"), "{error}");
10083 }
10084
10085 #[test]
10086 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10087 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10088 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10089 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10090
10091 let error = repo
10092 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10093 .unwrap_err();
10094
10095 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10096 assert!(error.to_string().contains("README.md"), "{error}");
10097 }
10098
10099 #[test]
10100 fn build_output_alone_does_not_keep_a_worktree() {
10101 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10102 exclude_paths(&repo, &["target/", "dist/"]);
10103 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10104 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10105 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10106 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10107
10108 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10109 repo.release_review_worktree(933);
10110
10111 assert!(!path.exists());
10112 }
10113
10114 #[test]
10115 fn finder_metadata_alone_does_not_keep_a_worktree() {
10116 let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10117 exclude_paths(&repo, &[".DS_Store"]);
10118 std::fs::create_dir_all(path.join("src")).unwrap();
10119 std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10120 std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10121
10122 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10123 repo.release_review_worktree(937);
10124
10125 assert!(!path.exists());
10126 }
10127
10128 #[test]
10129 fn a_fetch_record_alone_does_not_keep_a_worktree() {
10130 let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10131 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10132 let git_dir = if raw.is_absolute() {
10133 raw
10134 } else {
10135 path.join(raw)
10136 };
10137 let head = test_git(&path, &["rev-parse", "HEAD"]);
10138 std::fs::write(
10139 git_dir.join("FETCH_HEAD"),
10140 format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10141 )
10142 .unwrap();
10143
10144 assert!(!repository_has_recoverable_work(&path, true).unwrap());
10145 repo.release_review_worktree(938);
10146
10147 assert!(!path.exists());
10148 }
10149
10150 #[test]
10151 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10152 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10153 exclude_paths(&repo, &["target/", ".env.local"]);
10154 std::fs::create_dir_all(path.join("target/debug")).unwrap();
10155 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10156 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10157
10158 assert!(repository_has_recoverable_work(&path, true).unwrap());
10159 repo.release_review_worktree(934);
10160
10161 assert_eq!(
10162 "TOKEN=keep me\n",
10163 std::fs::read_to_string(path.join(".env.local")).unwrap()
10164 );
10165 }
10166
10167 #[test]
10168 fn a_repository_nested_in_build_output_keeps_a_worktree() {
10169 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10170 exclude_paths(&repo, &["node_modules/"]);
10171 let nested = path.join("node_modules/local-dep");
10172 std::fs::create_dir_all(&nested).unwrap();
10173 test_git(&nested, &["init"]);
10174 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10175
10176 assert!(repository_has_recoverable_work(&path, true).unwrap());
10177 repo.release_review_worktree(935);
10178
10179 assert!(nested.join(".git").exists());
10180 }
10181
10182 #[test]
10183 fn an_ordinary_untracked_file_keeps_a_worktree() {
10184 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10185 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10186
10187 assert!(repository_has_recoverable_work(&path, true).unwrap());
10188 repo.release_review_worktree(936);
10189
10190 assert_eq!(
10191 "somebody's notes\n",
10192 std::fs::read_to_string(path.join("notes.md")).unwrap()
10193 );
10194 }
10195
10196 #[test]
10197 fn a_legacy_crlf_checkout_is_retained_conservatively() {
10198 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10199 std::fs::write(
10200 path.join(".gitattributes"),
10201 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10202 )
10203 .unwrap();
10204 test_git(&path, &["add", ".gitattributes"]);
10205 test_git(&path, &["commit", "-m", "select legacy line endings"]);
10206
10207 assert!(repository_has_recoverable_work(&path, true).unwrap());
10208 }
10209
10210 #[test]
10211 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10212 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10213 let nested = path.join("tracked");
10214 std::fs::create_dir_all(&nested).unwrap();
10215 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10216 test_git(&path, &["add", "tracked/seed.txt"]);
10217 test_git(&path, &["commit", "-m", "add tracked directory"]);
10218 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10219 test_git(&nested, &["init"]);
10220
10221 let error = repo
10222 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10223 .unwrap_err();
10224
10225 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10226 assert!(error.to_string().contains("Git entry"), "{error}");
10227 assert!(nested.join(".git").exists());
10228 }
10229
10230 #[test]
10235 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10236 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10237 exclude_paths(&repo, &["target/"]);
10238 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10239 let repro = path.join("target/pr918-repro");
10240 std::fs::create_dir_all(&repro).unwrap();
10241 test_git(&repro, &["init"]);
10242 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10243
10244 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10245 .unwrap();
10246
10247 assert!(repository_has_recoverable_work(&path, true).unwrap());
10248 assert!(repro.join(".git").exists());
10249 }
10250
10251 #[test]
10255 fn a_repository_inside_a_nested_checkout_is_left_alone() {
10256 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10257 exclude_paths(&repo, &["target/"]);
10258 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10259 let repro = path.join("target/repro");
10260 let vendored = repro.join("vendor/dep");
10261 std::fs::create_dir_all(&vendored).unwrap();
10262 test_git(&repro, &["init"]);
10263 test_git(&vendored, &["init"]);
10264
10265 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10266 .unwrap();
10267
10268 assert!(vendored.join(".git").exists());
10269 }
10270
10271 #[test]
10272 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10273 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10274
10275 let state = ignored_untracked_state(repo.root()).unwrap();
10276
10277 let relative = path.strip_prefix(repo.root()).unwrap();
10278 assert!(
10279 state.files.contains_key(relative),
10280 "{:?}",
10281 state.files.keys().collect::<Vec<_>>()
10282 );
10283 assert!(state.is_ignored(relative));
10284 }
10285
10286 #[test]
10287 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10288 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10289 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10290 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10291 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10292
10293 repo.refuse_new_ignored_files(repo.root(), &baseline)
10294 .unwrap();
10295 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10296 .unwrap();
10297 }
10298
10299 #[test]
10300 fn deleting_a_resident_worktree_during_a_call_is_refused() {
10301 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10302 let baseline = repo.worktree_baseline(repo.root()).unwrap();
10303 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10304 std::fs::remove_dir_all(&path).unwrap();
10305
10306 let read_error = repo
10307 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10308 .unwrap_err();
10309 assert!(
10310 read_error.to_string().contains("review-932"),
10311 "{read_error}"
10312 );
10313 let error = repo
10314 .refuse_new_ignored_files(repo.root(), &baseline)
10315 .unwrap_err();
10316
10317 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10318 assert!(error.to_string().contains("review-932"), "{error}");
10319 }
10320
10321 #[test]
10322 fn a_nested_repository_record_is_read_as_a_plain_path() {
10323 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10324 assert_eq!(Path::new("vendor/checkout"), path);
10325 assert!(nested);
10326
10327 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10328 assert_eq!(Path::new("vendor/notes.txt"), path);
10329 assert!(!nested);
10330
10331 assert!(untracked_record(b"/", "untracked").is_err());
10332 }
10333
10334 #[cfg(unix)]
10335 #[test]
10336 fn a_non_utf8_git_path_is_preserved_without_loss() {
10337 use std::os::unix::ffi::OsStrExt;
10338
10339 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10340
10341 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10342 }
10343
10344 #[test]
10345 fn guarded_merge_pins_the_reviewed_head() {
10346 let args = merge_pr_args("36", Some("abc123"), true);
10347 assert_eq!(
10348 vec![
10349 "pr",
10350 "merge",
10351 "36",
10352 "--squash",
10353 "--delete-branch",
10354 "--match-head-commit",
10355 "abc123"
10356 ],
10357 args
10358 );
10359 }
10360
10361 #[test]
10362 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10363 let pr = PrRef {
10364 number: 7,
10365 url: "https://example.test/pull/7".into(),
10366 title: "part one".into(),
10367 };
10368 let result = reconcile_pr_creation(
10369 "split-34-1",
10370 Err(crate::error::SparError::new("connection lost")),
10371 Ok(Some(pr)),
10372 )
10373 .unwrap();
10374 assert_eq!(7, result.number);
10375 }
10376
10377 #[test]
10378 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10379 let error = reconcile_pr_creation(
10380 "split-34-1",
10381 Err(crate::error::SparError::new("permission denied")),
10382 Ok(None),
10383 )
10384 .unwrap_err();
10385 assert!(error.to_string().contains("permission denied"), "{error}");
10386 }
10387
10388 #[test]
10389 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10390 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10391 assert!(pr_for_base(text, "split-34-2", "split-34-1")
10392 .unwrap()
10393 .is_none());
10394 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10395 assert_eq!(7, found.number);
10396 }
10397
10398 #[test]
10399 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10400 let result = reconcile_comment_post(
10401 34,
10402 "the summary",
10403 crate::error::SparError::new("connection lost"),
10404 Ok(vec![serde_json::json!({"body": "the summary"})]),
10405 );
10406 assert!(result.is_ok(), "{result:?}");
10407 }
10408
10409 #[test]
10410 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10411 let error = reconcile_comment_post(
10412 34,
10413 "the summary",
10414 crate::error::SparError::new("connection lost"),
10415 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10416 )
10417 .unwrap_err();
10418 assert_eq!("connection lost", error.to_string());
10419 }
10420
10421 #[test]
10422 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10423 let error = reconcile_comment_post(
10424 34,
10425 "the summary",
10426 crate::error::SparError::new("connection lost"),
10427 Err(crate::error::SparError::new("comments unavailable")),
10428 )
10429 .unwrap_err();
10430 assert!(
10431 error.to_string().contains("could not be verified"),
10432 "{error}"
10433 );
10434 assert!(
10435 error.to_string().contains("comments unavailable"),
10436 "{error}"
10437 );
10438 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10439 assert!(!error.worth_retrying());
10440 }
10441
10442 #[test]
10443 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10444 let result = reconcile_issue_edit(
10445 34,
10446 "wanted body",
10447 crate::error::SparError::new("connection lost"),
10448 Ok("wanted body".to_string()),
10449 );
10450 assert!(result.is_ok(), "{result:?}");
10451 }
10452
10453 #[test]
10454 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10455 let error = reconcile_issue_edit(
10456 34,
10457 "wanted body",
10458 crate::error::SparError::new("connection lost"),
10459 Err(crate::error::SparError::new("issue unavailable")),
10460 )
10461 .unwrap_err();
10462 assert!(
10463 error.to_string().contains("could not be verified"),
10464 "{error}"
10465 );
10466 assert!(error.to_string().contains("issue unavailable"), "{error}");
10467 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10468 assert!(!error.worth_retrying());
10469 }
10470
10471 #[test]
10472 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10473 let found = ExistingIssue {
10474 number: 101,
10475 url: "https://example.test/issues/101".into(),
10476 title: "child".into(),
10477 body: "body".into(),
10478 open: true,
10479 };
10480 let url = reconcile_issue_creation(
10481 "child",
10482 Err(crate::error::SparError::new("connection lost")),
10483 Ok(Some(found)),
10484 )
10485 .unwrap();
10486 assert_eq!("https://example.test/issues/101", url);
10487 }
10488
10489 #[test]
10490 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10491 let error = reconcile_issue_creation(
10492 "child",
10493 Err(crate::error::SparError::new("permission denied")),
10494 Ok(None),
10495 )
10496 .unwrap_err();
10497 assert!(error.to_string().contains("permission denied"), "{error}");
10498 }
10499
10500 #[test]
10501 fn an_unverifiable_issue_creation_is_marked_uncertain() {
10502 let error = reconcile_issue_creation(
10503 "child",
10504 Err(crate::error::SparError::new("connection lost")),
10505 Err(crate::error::SparError::new("issues unavailable")),
10506 )
10507 .unwrap_err();
10508 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10509 assert!(!error.worth_retrying());
10510 }
10511
10512 #[test]
10513 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10514 let result = reconcile_failed_split_push(
10515 "split-34-1",
10516 crate::error::SparError::new("connection lost"),
10517 Ok("abc123\n".into()),
10518 Ok("abc123\trefs/heads/split-34-1\n".into()),
10519 );
10520 assert!(result.is_ok(), "{result:?}");
10521 }
10522
10523 #[test]
10524 fn a_split_push_collision_is_definite_and_never_overwrites() {
10525 let error = reconcile_failed_split_push(
10526 "split-34-1",
10527 crate::error::SparError::new("lease rejected"),
10528 Ok("abc123\n".into()),
10529 Ok("def456\trefs/heads/split-34-1\n".into()),
10530 )
10531 .unwrap_err();
10532 assert!(!error.retain_worktree());
10533 assert!(
10534 error.to_string().contains("Nothing was overwritten"),
10535 "{error}"
10536 );
10537 }
10538
10539 #[test]
10540 fn an_unreadable_split_push_result_keeps_the_worktree() {
10541 let error = reconcile_failed_split_push(
10542 "split-34-1",
10543 crate::error::SparError::new("connection lost"),
10544 Ok("abc123\n".into()),
10545 Err(crate::error::SparError::new("origin unavailable")),
10546 )
10547 .unwrap_err();
10548 assert!(error.retain_worktree());
10549 assert!(error.to_string().contains("could not confirm"), "{error}");
10550 }
10551
10552 #[test]
10556 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10557 let repo = repo_for_titles();
10558 for raw in [
10559 "Retry loop spins \u{2014} Retry-After parses to zero",
10560 "plain title",
10561 " spread over\nlines ",
10562 "\u{1F916} Generated with something",
10563 &format!("a \u{2014} {}", "very long title ".repeat(20)),
10564 &"x".repeat(300),
10565 &format!("{} \u{2014} end", "y".repeat(88)),
10566 &{
10571 let tail = "a\u{2014}b c\u{2014}d";
10572 let pad = Style::default().max_title_chars - tail.chars().count();
10573 format!("{}{tail}", "w".repeat(pad))
10574 },
10575 ] {
10576 let once = repo.clean_title(raw).unwrap();
10577 let twice = repo.clean_title(&once).unwrap();
10578 assert_eq!(once, twice, "not idempotent for {raw:?}");
10579 assert!(
10580 once.chars().count() <= repo.style.max_title_chars,
10581 "over budget: {once:?}"
10582 );
10583 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10584 }
10585 }
10586
10587 #[test]
10588 fn a_title_with_an_em_dash_survives_as_readable_text() {
10589 let repo = repo_for_titles();
10590 assert_eq!(
10591 "Retry loop spins, Retry-After parses to zero",
10592 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10593 .unwrap()
10594 );
10595 }
10596
10597 #[test]
10598 fn sh_quote_survives_a_quote() {
10599 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10600 }
10601
10602 #[test]
10603 fn sh_quote_wraps_a_space() {
10604 assert_eq!(
10605 "'/Applications/My App/spar'",
10606 sh_quote("/Applications/My App/spar")
10607 );
10608 }
10609
10610 #[test]
10611 fn finished_states_are_recognised_case_insensitively() {
10612 assert!(is_finished("MERGED"));
10613 assert!(is_finished("closed"));
10614 assert!(!is_finished("OPEN"));
10615 assert!(!is_finished(""));
10616 }
10617
10618 fn state() -> PersistedState {
10619 PersistedState {
10620 version: 1,
10621 checkpoint: 0,
10622 round: 4,
10623 next_actor: "codex".into(),
10624 status: Status::Pending,
10625 pr_head: "abc123".into(),
10626 ledger: Ledger::new(),
10627 filed: vec![],
10628 open_findings: vec![Finding {
10629 severity: Severity::Blocking,
10630 title: "Unchecked error".into(),
10631 detail: "the failure is discarded".into(),
10632 file: "src/a.rs:12".into(),
10633 ..Finding::default()
10634 }],
10635 disputes: vec![Dispute {
10636 title: "Retry limit".into(),
10637 file: "src/net.rs".into(),
10638 reasoning: "the caller already bounds it".into(),
10639 }],
10640 noted: vec![Finding {
10641 severity: Severity::NonBlocking,
10642 title: "Timeout is fixed".into(),
10643 file: "src/config.rs".into(),
10644 ..Finding::default()
10645 }],
10646 }
10647 }
10648
10649 #[test]
10650 fn a_state_comment_round_trips() {
10651 let body = format!(
10652 "{STATE_MARKER}\n{}\n-->",
10653 serde_json::to_string(&state()).unwrap()
10654 );
10655 let back = parse_state_comment(&body).unwrap();
10656 assert_eq!(4, back.round);
10657 assert_eq!("codex", back.next_actor);
10658 assert_eq!("abc123", back.pr_head);
10659 assert_eq!("Unchecked error", back.open_findings[0].title);
10660 assert_eq!("src/net.rs", back.disputes[0].file);
10661 assert_eq!("Timeout is fixed", back.noted[0].title);
10662 }
10663
10664 #[test]
10665 fn old_state_without_new_lists_still_parses() {
10666 let body = format!(
10667 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
10668 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10669 );
10670 let back = parse_state_comment(&body).expect("old state");
10671 assert!(back.open_findings.is_empty());
10672 assert!(back.disputes.is_empty());
10673 assert!(back.noted.is_empty());
10674 assert!(back.pr_head.is_empty());
10675 assert_eq!(0, back.checkpoint);
10676 }
10677
10678 #[test]
10679 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
10680 let mut local = state();
10681 local.pr_head = "old".into();
10682 local.round = 9;
10683 let mut remote = state();
10684 remote.pr_head = "current".into();
10685 remote.round = 4;
10686
10687 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10688 assert_eq!("current", chosen.pr_head);
10689 assert_eq!(4, chosen.round);
10690 }
10691
10692 #[test]
10693 fn checkpoint_order_breaks_same_round_ties() {
10694 let mut local = state();
10695 local.pr_head = "current".into();
10696 local.round = 4;
10697 local.checkpoint = 8;
10698 let mut remote = local.clone();
10699 remote.checkpoint = 7;
10700 remote.open_findings.clear();
10701
10702 let chosen = choose_state_for_head(vec![local], "current").unwrap();
10703 assert_eq!(8, chosen.checkpoint);
10704
10705 let mut local = state();
10706 local.pr_head = "current".into();
10707 local.round = 4;
10708 local.checkpoint = 8;
10709 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
10710 assert_eq!(8, chosen.checkpoint);
10711 }
10712
10713 #[test]
10714 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
10715 let mut local = state();
10716 local.pr_head = "current".into();
10717 local.round = 4;
10718 local.open_findings.push(Finding {
10719 title: "local checkpoint".into(),
10720 ..Finding::default()
10721 });
10722 let mut remote = state();
10723 remote.pr_head = "current".into();
10724 remote.round = 4;
10725
10726 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10727 assert_eq!(
10728 "local checkpoint",
10729 chosen.open_findings.last().unwrap().title
10730 );
10731 }
10732
10733 #[test]
10735 fn the_state_block_is_an_html_comment() {
10736 let body = format!(
10737 "{STATE_MARKER}\n{}\n-->",
10738 serde_json::to_string(&state()).unwrap()
10739 );
10740 assert!(body.starts_with("<!--"));
10741 assert!(body.trim_end().ends_with("-->"));
10742 assert!(!body[..body.find('{').unwrap()].contains("-->"));
10743 }
10744
10745 #[test]
10746 fn an_unrelated_json_block_is_not_state() {
10747 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
10748 }
10749
10750 #[test]
10757 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
10758 let block = format!(
10759 "{STATE_MARKER}\n{}\n-->",
10760 serde_json::to_string(&state()).unwrap()
10761 );
10762 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
10763 assert!(
10764 is_state_comment(&block, "Spar-Bot", "spar-bot"),
10765 "a login differs only in case"
10766 );
10767
10768 assert!(
10769 !is_state_comment(
10770 "why is there a `<!-- spar:state` block on this PR?",
10771 "maintainer",
10772 "spar-bot"
10773 ),
10774 "a question about the block was treated as the block"
10775 );
10776 assert!(
10777 !is_state_comment(&block, "maintainer", "spar-bot"),
10778 "somebody else's comment is not spar's to rewrite"
10779 );
10780 assert!(
10781 !is_state_comment(
10782 &format!("here is what spar writes:\n\n{block}"),
10783 "spar-bot",
10784 "spar-bot"
10785 ),
10786 "the marker has to open the body, not appear in it"
10787 );
10788 assert!(
10789 !is_state_comment(
10790 &format!("{STATE_MARKER}\n{{not json\n-->"),
10791 "spar-bot",
10792 "spar-bot"
10793 ),
10794 "a block that does not parse is not state"
10795 );
10796 assert!(
10797 !is_state_comment(&block, "spar-bot", ""),
10798 "an unknown viewer proves nothing about who wrote it"
10799 );
10800 }
10801
10802 #[test]
10803 fn a_malformed_state_comment_is_none_not_a_panic() {
10804 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
10805 }
10806
10807 #[test]
10808 fn atomic_write_leaves_no_temp_file() {
10809 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
10810 let _ = std::fs::remove_dir_all(&dir);
10811 let path = dir.join("state").join("pr-7.json");
10812 write_json_atomic(&path, &state()).unwrap();
10813 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
10814 .unwrap()
10815 .flatten()
10816 .filter_map(|e| e.file_name().to_str().map(str::to_string))
10817 .collect();
10818 assert_eq!(vec!["pr-7.json".to_string()], files);
10819 let _ = std::fs::remove_dir_all(&dir);
10820 }
10821
10822 #[test]
10823 fn atomic_write_overwrites_rather_than_accumulating() {
10824 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
10825 let _ = std::fs::remove_dir_all(&dir);
10826 let path = dir.join("pr-7.json");
10827 for round in 1..4 {
10828 let mut s = state();
10829 s.round = round;
10830 write_json_atomic(&path, &s).unwrap();
10831 }
10832 let back: PersistedState =
10833 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10834 assert_eq!(3, back.round);
10835 let _ = std::fs::remove_dir_all(&dir);
10836 }
10837
10838 #[test]
10839 fn style_from_env_defaults_to_enforcing() {
10840 std::env::remove_var("SPAR_BAN_EM_DASH");
10841 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
10842 let style = style_from_env();
10843 assert!(style.ban_em_dash && style.ban_ai_attribution);
10844 assert!(
10845 !style.terse,
10846 "the commit filter must not truncate a commit message"
10847 );
10848 }
10849
10850 #[test]
10855 fn a_push_denial_is_read_from_either_transport() {
10856 let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
10857 fatal: Could not read from remote repository.\n\n\
10858 Please make sure you have the correct access rights\n\
10859 and the repository exists.";
10860 let https = "remote: Permission to example/project.git denied to someone.\n\
10861 fatal: unable to access 'https://github.com/example/project.git/': The \
10862 requested URL returned error: 403";
10863 let denied = Some("Permission to example/project.git denied to someone.");
10864 assert_eq!(denied, push_denial(ssh));
10865 assert_eq!(denied, push_denial(https));
10866
10867 let stale = "To github.com:example/project.git\n \
10868 ! [rejected] HEAD -> issue-7 (stale info)\n\
10869 error: failed to push some refs to 'github.com:example/project.git'";
10870 assert_eq!(None, push_denial(stale));
10871
10872 let err = push_error(
10873 &PushTarget::Origin,
10874 "issue-7",
10875 &crate::error::SparError::new(ssh),
10876 );
10877 assert_eq!(
10878 "could not push to origin/issue-7. Permission to example/project.git denied to \
10879 someone.",
10880 err.to_string()
10881 );
10882 }
10883
10884 #[cfg(unix)]
10888 fn denied_fixture(
10889 tag: &str,
10890 fork_when_denied: bool,
10891 ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
10892 use std::os::unix::fs::PermissionsExt;
10893 use std::sync::atomic::{AtomicU32, Ordering};
10894 static NEXT: AtomicU32 = AtomicU32::new(0);
10895 let id = NEXT.fetch_add(1, Ordering::Relaxed);
10896 let root =
10897 std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
10898 let origin = root.join("origin.git");
10899 let fork = root.join("fork.git");
10900 let work = root.join("work");
10901 for dir in [&origin, &fork, &work] {
10902 std::fs::create_dir_all(dir).unwrap();
10903 }
10904 test_git(&origin, &["init", "--bare", "-b", "main"]);
10905 test_git(&fork, &["init", "--bare", "-b", "main"]);
10906 test_git(&work, &["init", "-b", "main"]);
10907 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
10908 test_git(&work, &["config", "user.name", "spar test"]);
10909 test_git(&work, &["config", "commit.gpgsign", "false"]);
10910 std::fs::write(work.join("README.md"), "seed\n").unwrap();
10911 test_git(&work, &["add", "."]);
10912 test_git(&work, &["commit", "-m", "seed"]);
10913 test_git(
10914 &work,
10915 &["remote", "add", "origin", origin.to_str().unwrap()],
10916 );
10917 test_git(&work, &["push", "-q", "origin", "main"]);
10918 let hook = origin.join("hooks").join("pre-receive");
10920 std::fs::write(
10921 &hook,
10922 "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
10923 )
10924 .unwrap();
10925 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10926 test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
10927 std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
10928 test_git(&work, &["add", "."]);
10929 test_git(&work, &["commit", "-m", "Fix it"]);
10930
10931 let mut text =
10932 String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
10933 if !fork_when_denied {
10934 text.push_str("[loop]\nfork_when_denied = false\n");
10935 }
10936 let cfg = crate::config::parse(&text).unwrap();
10937 let repo = Repo::open(&work, &cfg).unwrap();
10938 repo.fork
10940 .set(Fork {
10941 owner: "someone".into(),
10942 full_name: "someone/project".into(),
10943 url: fork.to_str().unwrap().into(),
10944 })
10945 .unwrap();
10946 (ReviewFixture { root }, repo, work, origin, fork)
10947 }
10948
10949 #[cfg(unix)]
10950 #[test]
10951 fn a_new_branch_origin_refuses_is_published_from_the_fork() {
10952 let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
10953 let first = test_git(&work, &["rev-parse", "HEAD"]);
10954
10955 let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
10956
10957 assert_eq!("someone:issue-7", target.head("issue-7"));
10958 assert_eq!(
10959 first.trim(),
10960 test_git(&fork, &["rev-parse", "issue-7"]).trim()
10961 );
10962 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
10963 .trim()
10964 .is_empty());
10965 assert_eq!(
10966 WriteSummary {
10967 attempted: 1,
10968 failed: 0
10969 },
10970 repo.write_summary(),
10971 "a refusal the fork answered is not a failed write"
10972 );
10973
10974 std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
10977 test_git(&work, &["commit", "-qam", "Fix it again"]);
10978 repo.push_to(&work, &target, "issue-7", Some(first.trim()))
10979 .unwrap();
10980 let second = test_git(&work, &["rev-parse", "HEAD"]);
10981 assert_eq!(
10982 second.trim(),
10983 test_git(&fork, &["rev-parse", "issue-7"]).trim()
10984 );
10985 }
10986
10987 #[cfg(unix)]
10991 #[test]
10992 fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
10993 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
10994 test_git(
10995 &work,
10996 &[
10997 "push",
10998 "-q",
10999 fork.to_str().unwrap(),
11000 "main:refs/heads/issue-7",
11001 ],
11002 );
11003 let head = test_git(&work, &["rev-parse", "HEAD"]);
11004
11005 repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11006
11007 assert_eq!(
11008 head.trim(),
11009 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11010 );
11011 assert_eq!(0, repo.write_summary().failed);
11012 }
11013
11014 #[cfg(unix)]
11015 #[test]
11016 fn work_only_the_fork_has_is_not_published_over() {
11017 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11018 test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11019 std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11020 test_git(&work, &["add", "."]);
11021 test_git(&work, &["commit", "-qm", "Somebody's work"]);
11022 test_git(
11023 &work,
11024 &[
11025 "push",
11026 "-q",
11027 fork.to_str().unwrap(),
11028 "elsewhere:refs/heads/issue-7",
11029 ],
11030 );
11031 let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11032 test_git(&work, &["checkout", "-q", "issue-7"]);
11033
11034 let err = repo
11035 .publish_new_branch(&work, "issue-7", "main")
11036 .unwrap_err()
11037 .to_string();
11038
11039 assert!(
11040 err.contains(
11041 "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11042 ),
11043 "{err}"
11044 );
11045 assert!(err.contains("--delete issue-7"), "{err}");
11046 assert_eq!(
11047 theirs.trim(),
11048 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11049 );
11050 assert_eq!(
11051 WriteSummary {
11052 attempted: 1,
11053 failed: 1
11054 },
11055 repo.write_summary()
11056 );
11057 }
11058
11059 #[test]
11061 fn a_rejected_push_names_the_ref_it_rejected() {
11062 let stale = "To github.com:example/project.git\n \
11063 ! [rejected] HEAD -> issue-7 (stale info)\n\
11064 error: failed to push some refs to 'github.com:example/project.git'";
11065
11066 let err = push_error(
11067 &PushTarget::Origin,
11068 "issue-7",
11069 &crate::error::SparError::new(stale),
11070 )
11071 .to_string();
11072
11073 assert!(
11074 err.starts_with(
11075 "could not push to origin/issue-7. [rejected] HEAD -> issue-7 (stale info)"
11076 ),
11077 "{err}"
11078 );
11079 }
11080
11081 #[test]
11082 fn a_fork_is_reached_by_whatever_reaches_origin() {
11083 let cases = [
11084 (
11085 "git@github.com:example/project.git",
11086 Some("git@github.com:someone/project.git"),
11087 ),
11088 (
11090 "github.com:example/project.git",
11091 Some("github.com:someone/project.git"),
11092 ),
11093 (
11095 "git@github-work:example/project.git",
11096 Some("git@github-work:someone/project.git"),
11097 ),
11098 (
11100 "ssh://git@ssh.github.com:443/example/project.git",
11101 Some("ssh://git@ssh.github.com:443/someone/project.git"),
11102 ),
11103 (
11104 "https://github.com/example/project",
11105 Some("https://github.com/someone/project"),
11106 ),
11107 (
11108 "https://ghe.example.com/scm/example/project.git/",
11109 Some("https://ghe.example.com/scm/someone/project.git"),
11110 ),
11111 (
11112 "git@host:/srv/example/project.git",
11113 Some("git@host:/srv/someone/project.git"),
11114 ),
11115 ("/srv/git/project.git", None),
11116 ("https://github.com/project", None),
11117 ("", None),
11118 ];
11119 for (origin, want) in cases {
11120 assert_eq!(
11121 want.map(str::to_string),
11122 fork_url(origin, "someone/project"),
11123 "{origin}"
11124 );
11125 }
11126 }
11127
11128 #[test]
11129 fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11130 let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11131 "ssh_url":"git@github.com:someone/project.git",
11132 "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11133 let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11134 "sshUrl":"git@github.com:someone/project.git",
11135 "url":"https://github.com/someone/project"}]"#;
11136
11137 let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11138 assert_eq!("git@github-work:someone/project.git", fork.url);
11139 assert_eq!("someone", fork.owner);
11140 assert_eq!("someone/project", fork.full_name);
11141 let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11142 .unwrap()
11143 .unwrap();
11144 assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11145 assert_eq!(
11146 None,
11147 fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11148 );
11149
11150 let fork = fork_from_api(made, "https://github.com/project").unwrap();
11153 assert_eq!("https://github.com/someone/project.git", fork.url);
11154 let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11155 assert_eq!("git@github.com:someone/project.git", fork.url);
11156
11157 assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11158 assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11159 }
11160
11161 #[test]
11162 fn only_the_viewers_own_fork_can_be_pushed_to() {
11163 let pr = |owner: &str, name: &str| PrView {
11164 number: 9,
11165 url: String::new(),
11166 title: String::new(),
11167 body: String::new(),
11168 head_ref_name: "issue-7".into(),
11169 base_ref_name: "main".into(),
11170 state: "OPEN".into(),
11171 closing_issues_references: Vec::new(),
11172 is_cross_repository: true,
11173 head_repository_owner: Some(crate::model::Author {
11174 login: owner.into(),
11175 }),
11176 head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11177 };
11178 let origin = "git@github.com:example/project.git";
11179
11180 assert_eq!(
11181 Some(PushTarget::Fork(Fork {
11182 owner: "Someone".into(),
11183 full_name: "Someone/project".into(),
11184 url: "git@github.com:Someone/project.git".into(),
11185 })),
11186 own_fork_target(&pr("Someone", "project"), "someone", origin)
11187 );
11188 assert_eq!(
11189 None,
11190 own_fork_target(&pr("other", "project"), "someone", origin)
11191 );
11192 assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11193 assert_eq!(
11194 None,
11195 own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11196 );
11197 let mut unnamed = pr("someone", "project");
11198 unnamed.head_repository_owner = None;
11199 assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11200 }
11201
11202 #[cfg(unix)]
11205 #[test]
11206 fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11207 let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11208 test_git(
11209 &work,
11210 &[
11211 "push",
11212 "-q",
11213 fork.to_str().unwrap(),
11214 "issue-7:refs/heads/issue-7",
11215 ],
11216 );
11217 let published = test_git(&work, &["rev-parse", "issue-7"]);
11218 test_git(&work, &["checkout", "-q", "main"]);
11219 let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11220 let pr = PrView {
11221 number: 31,
11222 url: String::new(),
11223 title: String::new(),
11224 body: String::new(),
11225 head_ref_name: "issue-7".into(),
11226 base_ref_name: "main".into(),
11227 state: "OPEN".into(),
11228 closing_issues_references: Vec::new(),
11229 is_cross_repository: true,
11230 head_repository_owner: None,
11231 head_repository: None,
11232 };
11233
11234 let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11235
11236 assert_eq!("issue-7", branch);
11237 assert_eq!(
11238 published.trim(),
11239 test_git(&path, &["rev-parse", "HEAD"]).trim()
11240 );
11241 std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11242 test_git(&path, &["commit", "-qam", "Answer a comment"]);
11243 repo.push_to(&path, &target, &branch, Some(published.trim()))
11244 .unwrap();
11245 assert_eq!(
11246 test_git(&path, &["rev-parse", "HEAD"]).trim(),
11247 test_git(&fork, &["rev-parse", "issue-7"]).trim()
11248 );
11249 assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11250 .trim()
11251 .is_empty());
11252 }
11253
11254 #[cfg(unix)]
11255 #[test]
11256 fn with_forking_off_a_refused_push_says_why_and_how() {
11257 let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11258
11259 let err = repo
11260 .publish_new_branch(&work, "issue-7", "main")
11261 .unwrap_err()
11262 .to_string();
11263
11264 assert!(
11265 err.contains("Permission to example/project.git denied to someone."),
11266 "{err}"
11267 );
11268 assert!(err.contains("fork_when_denied = true"), "{err}");
11269 assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11270 .trim()
11271 .is_empty());
11272 assert_eq!(
11273 WriteSummary {
11274 attempted: 1,
11275 failed: 1
11276 },
11277 repo.write_summary()
11278 );
11279 }
11280}
11281
11282#[cfg(test)]
11283mod comment_page_tests {
11284 use super::*;
11285
11286 #[test]
11287 fn a_single_merged_array_is_read() {
11288 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11289 assert_eq!(2, pages.len());
11290 assert_eq!(Some(2), pages[1]["id"].as_i64());
11291 }
11292
11293 #[test]
11294 fn concatenated_pages_from_an_older_gh_are_read_too() {
11295 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11296 assert_eq!(2, pages.len());
11297 }
11298
11299 #[test]
11303 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11304 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11305 let pages = parse_comment_pages(text);
11306 assert_eq!(2, pages.len(), "{pages:?}");
11307 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11308 }
11309
11310 #[test]
11311 fn empty_output_is_no_comments_not_a_panic() {
11312 assert!(parse_comment_pages("").is_empty());
11313 assert!(parse_comment_pages(" ").is_empty());
11314 assert!(parse_comment_pages("[]").is_empty());
11315 }
11316
11317 #[test]
11318 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11319 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11320 }
11321
11322 #[test]
11323 fn a_write_postcheck_rejects_truncated_comment_pages() {
11324 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11325 assert!(
11326 error.to_string().contains("unexpected comment pages"),
11327 "{error}"
11328 );
11329 }
11330
11331 #[test]
11332 fn a_write_postcheck_rejects_empty_or_non_array_output() {
11333 assert!(try_parse_comment_pages("").is_err());
11334 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11335 assert!(try_parse_comment_pages("[]").is_ok());
11336 }
11337
11338 #[test]
11339 fn state_is_found_in_the_last_matching_comment() {
11340 let payload = |round: u32| {
11341 format!(
11342 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11343 )
11344 };
11345 let text = serde_json::to_string(&serde_json::json!([
11346 {"id": 1, "body": payload(1)},
11347 {"id": 2, "body": "looks good to me"},
11348 {"id": 3, "body": payload(5)},
11349 ]))
11350 .unwrap();
11351 let pages = parse_comment_pages(&text);
11352 let last = pages
11353 .iter()
11354 .rev()
11355 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11356 .unwrap();
11357 assert_eq!(5, last.round);
11358 }
11359}
11360
11361#[cfg(test)]
11362mod linked_pr_tests {
11363 use super::*;
11364
11365 const REAL_PAYLOAD: &str = r#"[
11370 {"number":14252,"title":"fix: reject leading-dash branch names",
11371 "url":"https://github.com/cli/cli/pull/14252",
11372 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11373 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11374 "url":"https://github.com/cli/cli/issues/14238"}]},
11375 {"number":14217,"title":"another change",
11376 "url":"https://github.com/cli/cli/pull/14217",
11377 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11378 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11379 "url":"https://github.com/cli/cli/issues/9761"}]},
11380 {"number":14200,"title":"unlinked work",
11381 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11382 ]"#;
11383
11384 #[test]
11385 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11386 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11387 assert_eq!(14252, pr.number);
11388 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11389 }
11390
11391 #[test]
11392 fn the_right_pr_is_picked_out_of_several() {
11393 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11394 }
11395
11396 #[test]
11397 fn an_issue_nobody_is_working_on_finds_nothing() {
11398 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11399 }
11400
11401 #[test]
11402 fn an_unlinked_pr_is_never_matched() {
11403 for issue in [14200, 0, 1] {
11405 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11406 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11407 }
11408 }
11409 }
11410
11411 #[test]
11412 fn empty_or_broken_output_is_none_rather_than_a_panic() {
11413 assert!(find_linked_pr("", 1).is_none());
11414 assert!(find_linked_pr("[]", 1).is_none());
11415 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11416 assert!(find_linked_pr("[{\"number\":", 1).is_none());
11417 }
11418
11419 #[test]
11426 fn an_unreadable_list_is_not_an_empty_list() {
11427 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11428 assert!(linked_pr_rows("[{\"number\":").is_none());
11429 assert!(linked_pr_rows("").is_none());
11430
11431 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11432 assert!(empty.is_empty());
11433 assert!(pick_linked_pr(&empty, 14238).is_none());
11434
11435 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11436 assert_eq!(3, listed.len());
11437 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11438 }
11439
11440 #[test]
11442 fn pr_view_reads_the_cross_repository_flag() {
11443 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11444 "baseRefName":"main","state":"OPEN",
11445 "closingIssuesReferences":[],"isCrossRepository":true}"#;
11446 let pr: PrView = serde_json::from_str(json).unwrap();
11447 assert!(pr.is_cross_repository);
11448 assert!(pr.is_open());
11449
11450 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11451 assert!(
11452 !serde_json::from_str::<PrView>(&same_repo)
11453 .unwrap()
11454 .is_cross_repository
11455 );
11456 }
11457}
11458
11459#[cfg(test)]
11460mod min_number_tests {
11461 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11467 let mut numbers: Vec<i64> = open.to_vec();
11468 numbers.sort_unstable();
11469 if min_number > 0 {
11470 numbers.retain(|n| *n >= min_number);
11471 }
11472 numbers.truncate(limit);
11473 numbers
11474 }
11475
11476 #[test]
11477 fn the_floor_is_applied_before_the_cap_not_after() {
11478 let open = [12, 13, 14, 480, 481, 482];
11479 assert_eq!(vec![480, 481], pick(&open, 2, 480));
11480 assert!(!pick(&open, 2, 480).is_empty());
11483 }
11484
11485 #[test]
11486 fn no_floor_keeps_the_old_behaviour() {
11487 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11488 }
11489
11490 #[test]
11491 fn the_floor_is_inclusive() {
11492 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11493 }
11494
11495 #[test]
11496 fn a_floor_above_everything_open_yields_nothing() {
11497 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11498 }
11499}
11500
11501#[cfg(test)]
11502mod review_record_tests {
11503 use super::*;
11504 use serde_json::json;
11505
11506 #[test]
11507 fn prune_state_knows_which_files_belong_to_a_pull_request() {
11508 assert_eq!(Some(12), state_file_number("pr-12.json"));
11509 assert_eq!(Some(12), state_file_number("review-12.json"));
11510 assert_eq!(Some(12), state_file_number("checkin-12.json"));
11511 for shared in [
11512 "spend.json",
11513 "implementors.json",
11514 "contested.json",
11515 "listen.json",
11516 "pr-.json",
11517 "pr-12.json.tmp",
11518 "pr-x.json",
11519 ] {
11520 assert_eq!(None, state_file_number(shared), "{shared}");
11521 }
11522 }
11523
11524 #[test]
11525 fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11526 let reviews = vec![
11527 json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11528 json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11529 ];
11530 assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11531 assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11532 assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11533 }
11534
11535 #[test]
11539 fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11540 let older = vec![
11541 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11542 ];
11543 assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11544 let dismissed = vec![
11545 json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11546 ];
11547 assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11548 }
11549
11550 #[test]
11551 fn a_response_splits_into_status_headers_and_body() {
11552 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";
11553 let resp = parse_api_response(text).expect("a response");
11554 assert_eq!(200, resp.status);
11555 assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11556 assert_eq!(
11557 Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11558 resp.headers.get("last-modified")
11559 );
11560 assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11561
11562 let not_modified =
11563 parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11564 assert_eq!(304, not_modified.status);
11565 assert!(not_modified.body.is_empty());
11566
11567 assert_eq!(None, parse_api_response(""));
11568 assert_eq!(None, parse_api_response("gh: command not found\n"));
11569 }
11570
11571 #[test]
11572 fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11573 let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11574 {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11575 {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11576 {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11577 {}
11578 ]}}}}})
11579 .to_string();
11580 let made = parse_review_requests(&text).expect("requests");
11581 assert_eq!(2, made.len());
11582 assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11583 assert_eq!("viewer", made[0].who.login);
11584 assert!(made[0].who.slug.is_empty());
11585 assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11586 assert!(made[1].who.login.is_empty());
11587 assert_eq!("example/ours", made[1].who.slug);
11588
11589 let none =
11590 json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11591 assert_eq!(
11592 Some(0),
11593 parse_review_requests(&none.to_string()).map(|m| m.len())
11594 );
11595 assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11596 assert!(parse_review_requests("gh: not json").is_none());
11597 }
11598
11599 #[test]
11600 fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11601 let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11602 let error = crate::error::SparError::call_failed("gh: boom");
11603 assert!(
11604 reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11605 );
11606
11607 let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11608 let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11609 .unwrap_err();
11610 assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11611
11612 let unreadable = Err(crate::error::SparError::new("no network"));
11613 let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11614 assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11615 assert!(err.to_string().contains("could not be verified"), "{err}");
11616 }
11617 #[test]
11618 fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
11619 let comments = vec![
11620 InlineComment {
11621 path: "src/a.rs".into(),
11622 line: 12,
11623 start_line: None,
11624 body: "one".into(),
11625 },
11626 InlineComment {
11627 path: "src/b.rs".into(),
11628 line: 9,
11629 start_line: Some(4),
11630 body: "two".into(),
11631 },
11632 ];
11633 assert_eq!(
11634 json!({
11635 "commit_id": "abc",
11636 "event": "COMMENT",
11637 "body": "the body",
11638 "comments": [
11639 {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
11640 {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
11641 "start_side": "RIGHT", "body": "two"},
11642 ],
11643 }),
11644 review_payload("abc", "COMMENT", "the body", &comments)
11645 );
11646 }
11647
11648 #[test]
11649 fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
11650 let diff = r#"diff --git a/src/a.rs b/src/a.rs
11651index 1111111..2222222 100644
11652--- a/src/a.rs
11653+++ b/src/a.rs
11654@@ -1,3 +1,4 @@ fn x
11655 one
11656+++ an added line that looks like a header
11657 two
11658 three
11659@@ -20 +21,2 @@
11660-old
11661+new
11662+more
11663@@ -30,1 +32,0 @@
11664-gone
11665diff --git a/gone.rs b/gone.rs
11666deleted file mode 100644
11667--- a/gone.rs
11668+++ /dev/null
11669@@ -1,2 +0,0 @@
11670-a
11671-b
11672diff --git a/old.rs b/renamed.rs
11673similarity index 90%
11674rename from old.rs
11675rename to renamed.rs
11676--- a/old.rs
11677+++ b/renamed.rs
11678@@ -5,0 +6,1 @@
11679+inserted
11680diff --git a/with space.rs b/with space.rs
11681--- a/with space.rs
11682+++ b/with space.rs
11683@@ -2 +2 @@
11684-x
11685+y
11686diff --git "a/sp\tace.rs" "b/sp\tace.rs"
11687--- "a/sp\tace.rs"
11688+++ "b/sp\tace.rs"
11689@@ -1 +1 @@
11690-x
11691+y
11692"#;
11693 let hunks = new_side_hunks(diff);
11694 assert_eq!(
11695 Hunks::from([
11696 ("renamed.rs".to_string(), vec![(6, 6)]),
11697 ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
11698 ("with space.rs".to_string(), vec![(2, 2)]),
11699 ]),
11700 hunks
11701 );
11702 }
11703
11704 #[test]
11705 fn only_a_refusal_is_final_enough_to_send_a_review_again() {
11706 let error = |text: &str| crate::error::SparError::new(text);
11707 assert!(refused(&error(
11708 "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
11709 )));
11710 assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
11711 assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
11712 assert!(!refused(&error("command timed out after 300s")));
11713 }
11714
11715 #[test]
11716 fn a_hunk_header_with_its_counts_left_out_counts_one() {
11717 assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
11718 assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
11719 assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
11720 assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
11721 }
11722}