1use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20 Contested, ContestedItem, Followup, Issue, IssueRef, ItemKind, PersistedState, PrRef, PrRow,
21 PrView,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, logdim, logwarn, spar_err};
27
28pub const FETCH_CEILING: usize = 500;
32
33pub const STATE_MARKER: &str = "<!-- spar:state";
36
37pub const COMMENT_MARKER: &str = "<!-- spar:comment -->";
45
46pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
54
55const WORKTREE_DIR: &str = ".spar-worktrees";
56const STATE_DIR: &str = ".spar";
57
58pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
60pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub(crate) enum Disposable {
65 BuildOutput,
68 ReviewOutput,
76}
77
78pub(crate) fn is_spar_marker(path: &Path) -> bool {
86 let mut components = path.components();
87 let Some(std::path::Component::Normal(name)) = components.next() else {
88 return false;
89 };
90 if components.next().is_some() {
91 return false;
92 }
93 let Some(name) = name.to_str() else {
94 return false;
95 };
96 name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
97}
98
99pub(crate) fn is_spar_own_path(path: &Path) -> bool {
112 if is_spar_marker(path) {
113 return true;
114 }
115 let Some(std::path::Component::Normal(name)) = path.components().next() else {
116 return false;
117 };
118 name == OsStr::new(STATE_DIR)
119}
120
121const SPLIT_SLOTS: u32 = 20;
126
127#[derive(Debug, Clone)]
128pub struct SplitPushError {
129 message: String,
130 retain_worktree: bool,
131}
132
133impl SplitPushError {
134 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
135 Self {
136 message: message.into(),
137 retain_worktree,
138 }
139 }
140
141 pub fn retain_worktree(&self) -> bool {
142 self.retain_worktree
143 }
144}
145
146impl std::fmt::Display for SplitPushError {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.write_str(&self.message)
149 }
150}
151
152impl std::error::Error for SplitPushError {}
153
154fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
159 match attempt {
160 1 => format!("split-{parent}-{index}"),
161 n => format!("split-{parent}-{index}-{n}"),
162 }
163}
164
165#[derive(Debug)]
166pub struct Repo {
167 root: PathBuf,
168 pub style: Style,
169 pub branch_prefix: String,
170 pub state_store: StateStore,
171 pub followups: Followups,
172 pub drafts: Drafts,
173 viewer: OnceLock<String>,
179 checkpoints: Mutex<BTreeMap<i64, u64>>,
184 writes: WriteStats,
185}
186
187#[derive(Debug, Default)]
188struct WriteStats {
189 attempted: AtomicUsize,
190 failed: AtomicUsize,
191}
192
193#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
194pub(crate) struct WriteSummary {
195 pub(crate) attempted: usize,
196 pub(crate) failed: usize,
197}
198
199impl WriteSummary {
200 pub(crate) fn succeeded(self) -> usize {
201 self.attempted.saturating_sub(self.failed)
202 }
203}
204
205#[derive(Debug, Clone)]
211pub(crate) struct WorktreeBaseline {
212 attributes: AttributeState,
213 ignored_untracked: IgnoredState,
214 git_state: GitState,
215}
216
217#[derive(Debug, Clone)]
223pub(crate) struct WorktreeCheckpoint {
224 path: PathBuf,
225 attributes: AttributeState,
226 git_state: GitState,
227 ignored_untracked: IgnoredState,
228}
229
230#[derive(Debug, Clone, Default, PartialEq, Eq)]
231pub(crate) struct AttributeState {
232 files: BTreeMap<PathBuf, [u8; 32]>,
233}
234
235#[derive(Debug, Clone, Default, PartialEq, Eq)]
240pub(crate) struct IgnoredState {
241 files: BTreeMap<PathBuf, UntrackedFile>,
242 ignored: BTreeSet<PathBuf>,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
252struct UntrackedFile {
253 kind: u8,
254 len: u64,
255 modified: Option<std::time::SystemTime>,
256 created: Option<std::time::SystemTime>,
257 readonly: bool,
258 symlink_target: Option<Vec<u8>>,
259 #[cfg(unix)]
260 device: u64,
261 #[cfg(unix)]
262 inode: u64,
263 #[cfg(unix)]
264 mode: u32,
265 #[cfg(unix)]
266 change_seconds: i64,
267 #[cfg(unix)]
268 change_nanoseconds: i64,
269}
270
271#[derive(Debug, Clone, Default, PartialEq, Eq)]
272pub(crate) struct GitState {
273 repositories: BTreeMap<PathBuf, RepositoryState>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
277struct RepositoryState {
278 head: String,
279 unsafe_index_flags: Vec<u8>,
280 tracked: BTreeMap<PathBuf, TrackedEntry>,
281 gitlinks: BTreeMap<PathBuf, String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
285struct TrackedEntry {
286 index_mode: String,
287 index_oid: String,
288 worktree: Option<WorktreeFile>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
292struct WorktreeFile {
293 mode: String,
294 #[cfg(unix)]
295 permissions: u32,
296 raw_oid: String,
297 fingerprint: [u8; 32],
298 content: [u8; 32],
299}
300
301struct Gitlink {
302 path: PathBuf,
303 oid: String,
304}
305
306struct IndexEntry {
307 path: PathBuf,
308 mode: String,
309 oid: String,
310}
311
312impl IgnoredState {
313 fn is_ignored(&self, path: &Path) -> bool {
314 self.ignored.contains(path)
315 }
316
317 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
318 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
319 paths.extend(after.files.keys().cloned());
320 paths
321 .into_iter()
322 .filter(|path| {
323 self.files.get(path) != after.files.get(path)
324 || self.is_ignored(path) != after.is_ignored(path)
325 })
326 .collect()
327 }
328
329 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
330 self.files
331 .iter()
332 .filter(|(path, state)| after.files.get(*path) != Some(*state))
333 .map(|(path, _)| path.clone())
334 .collect()
335 }
336
337 fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
338 after
339 .files
340 .keys()
341 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
342 .cloned()
343 .collect()
344 }
345
346 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
356 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
357 paths.extend(after.files.keys());
358 paths.into_iter().any(|path| {
359 if self.files.get(path) == after.files.get(path)
360 && self.is_ignored(path) == after.is_ignored(path)
361 {
362 return false;
363 }
364 !self.generated_move(after, path)
365 })
366 }
367
368 pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
396 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
397 paths.extend(after.files.keys());
398 let mut leavings = ReadOnlyLeavings::default();
399 for path in paths {
400 if self.files.get(path) == after.files.get(path)
401 && self.is_ignored(path) == after.is_ignored(path)
402 {
403 continue;
404 }
405 if !self.ignored_file_move(after, path) {
406 leavings.condemning.push(path.clone());
407 } else if !is_generated_artifact(path) {
408 leavings.tolerated.push(path.clone());
409 }
410 }
411 leavings
412 }
413
414 fn generated_move(&self, after: &Self, path: &Path) -> bool {
428 is_generated_artifact(path) && self.disposable_move(after, path)
429 }
430
431 fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
435 if path.starts_with(WORKTREE_DIR)
436 && self.files.get(path).is_some_and(|file| file.kind == 3)
437 && self.files.get(path) != after.files.get(path)
438 {
439 return false;
440 }
441 self.disposable_move(after, path)
442 }
443
444 fn disposable_move(&self, after: &Self, path: &Path) -> bool {
447 self.disposable(path) && after.disposable(path)
448 }
449
450 fn disposable(&self, path: &Path) -> bool {
453 !self.files.contains_key(path) || self.is_ignored(path)
454 }
455}
456
457impl GitState {
458 pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
464 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
465 prefixes.extend(after.repositories.keys());
466 let mut notes: Vec<String> = Vec::new();
467 let mut paths: Vec<PathBuf> = Vec::new();
468 for prefix in prefixes {
469 let label = describe_repository(prefix);
470 match (
471 self.repositories.get(prefix),
472 after.repositories.get(prefix),
473 ) {
474 (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
475 (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
476 (Some(before), Some(current)) => {
477 if before.head != current.head {
478 notes.push(format!("HEAD moved in {label}"));
479 }
480 if before.unsafe_index_flags != current.unsafe_index_flags {
481 notes.push(format!("index flags changed in {label}"));
482 }
483 paths.extend(
484 differing_keys(&before.gitlinks, ¤t.gitlinks)
485 .chain(differing_keys(&before.tracked, ¤t.tracked))
486 .map(|path| prefix.join(path)),
487 );
488 }
489 (None, None) => {}
490 }
491 }
492 if !paths.is_empty() {
493 paths.sort();
494 paths.dedup();
495 notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
496 }
497 (!notes.is_empty()).then(|| notes.join("; "))
498 }
499
500 pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
503 let difference = self.describe_difference(after)?;
504 let Some(moved) = self.moved_wholesale(after) else {
505 return Some(difference);
506 };
507 if said_once("moved", cwd, &moved) {
510 logwarn!(
511 "the checkout at {} moved while a read-only call was reading it: {moved}. The \
512 answer was kept, and it describes the tree as it stood before the move.",
513 cwd.display()
514 );
515 }
516 None
517 }
518
519 fn moved_wholesale(&self, after: &Self) -> Option<String> {
536 let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
537 prefixes.extend(after.repositories.keys());
538 let mut notes: Vec<String> = Vec::new();
539 for prefix in prefixes {
540 let before = self.repositories.get(prefix)?;
542 let current = after.repositories.get(prefix)?;
543 if before.unsafe_index_flags != current.unsafe_index_flags
544 || before.gitlinks != current.gitlinks
545 {
546 return None;
547 }
548 let moved: Vec<&PathBuf> = differing_keys(&before.tracked, ¤t.tracked).collect();
549 if before.head == current.head {
550 if moved.is_empty() {
551 continue;
552 }
553 return None;
554 }
555 if !moved.iter().all(|path| {
556 checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
557 }) {
558 return None;
559 }
560 let label = describe_repository(prefix);
561 notes.push(match moved.len() {
562 0 => format!("HEAD moved in {label}"),
563 count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
564 });
565 }
566 (!notes.is_empty()).then(|| notes.join("; "))
567 }
568}
569
570fn checked_out(entry: Option<&TrackedEntry>) -> bool {
577 entry.is_none_or(|entry| {
578 entry
579 .worktree
580 .as_ref()
581 .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
582 })
583}
584
585pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
596 if paths.is_empty() {
597 return;
598 }
599 let named = list_paths(paths);
600 if !said_once("ignored", cwd, &named) {
601 return;
602 }
603 logdim!(
604 "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
605 project ignores these paths.",
606 paths.len(),
607 cwd.display()
608 );
609}
610
611fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
618 static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
619 SAID.lock()
620 .unwrap_or_else(std::sync::PoisonError::into_inner)
621 .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
622}
623
624fn describe_repository(prefix: &Path) -> String {
627 if prefix.as_os_str().is_empty() {
628 "the worktree".to_string()
629 } else {
630 format!("the submodule at {:?}", prefix.as_os_str())
631 }
632}
633
634fn differing_keys<'a, V: PartialEq>(
636 before: &'a BTreeMap<PathBuf, V>,
637 after: &'a BTreeMap<PathBuf, V>,
638) -> impl Iterator<Item = &'a PathBuf> {
639 let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
640 keys.extend(after.keys());
641 keys.into_iter()
642 .filter(move |key| before.get(*key) != after.get(*key))
643}
644
645#[derive(Default)]
651pub(crate) struct ReadOnlyLeavings {
652 pub(crate) tolerated: Vec<PathBuf>,
656 pub(crate) condemning: Vec<PathBuf>,
658}
659
660pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
664 match (first, second) {
665 (None, None) => None,
666 (Some(one), None) | (None, Some(one)) => Some(one),
667 (Some(first), Some(second)) => Some(format!("{first}; {second}")),
668 }
669}
670
671pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
676 const SHOWN: usize = 5;
677 let mut listed = paths
678 .iter()
679 .take(SHOWN)
680 .map(|path| format!("{:?}", path.as_os_str()))
681 .collect::<Vec<_>>()
682 .join(", ");
683 if paths.len() > SHOWN {
684 listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
685 }
686 listed
687}
688
689#[derive(Default)]
696struct IgnoredArtifacts {
697 new_paths: BTreeSet<PathBuf>,
698 changed_paths: BTreeSet<PathBuf>,
699 removed_paths: BTreeSet<PathBuf>,
700}
701
702impl IgnoredArtifacts {
703 fn left(&mut self, allowed: AllowedArtifacts) {
704 self.new_paths.extend(allowed.present);
705 self.removed_paths.extend(allowed.removed);
706 }
707
708 fn changed(&mut self, allowed: AllowedArtifacts) {
709 self.changed_paths.extend(allowed.present);
710 self.removed_paths.extend(allowed.removed);
711 }
712
713 fn report(&self, cwd: &Path) {
715 let paths: BTreeSet<_> = self
716 .new_paths
717 .iter()
718 .chain(&self.changed_paths)
719 .chain(&self.removed_paths)
720 .cloned()
721 .collect();
722 if !paths.is_empty() {
723 logdim!(
724 "the editing call changed {} ignored file(s) in {}: {}. They are outside \
725 the commit; cleanup checks them separately.",
726 paths.len(),
727 cwd.display(),
728 list_paths(&paths.into_iter().collect::<Vec<_>>())
729 );
730 }
731 }
732}
733
734#[derive(Default)]
738struct AllowedArtifacts {
739 present: Vec<PathBuf>,
740 removed: Vec<PathBuf>,
741}
742
743impl AllowedArtifacts {
744 fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
745 let (present, removed) = paths
746 .into_iter()
747 .partition(|path| after.files.contains_key(path));
748 Self { present, removed }
749 }
750}
751
752fn is_generated_artifact(path: &Path) -> bool {
758 const DIRECTORIES: &[&str] = &[
759 "target",
760 "dist",
761 "node_modules",
762 "__pycache__",
763 ".pytest_cache",
764 ".mypy_cache",
765 ".ruff_cache",
766 ".tox",
767 ".nox",
768 ".venv",
769 "venv",
770 ".gradle",
771 ".build",
772 "DerivedData",
773 ".next",
774 ".nuxt",
775 ".svelte-kit",
776 ".turbo",
777 "coverage",
778 ];
779 path.components().any(|component| {
780 let std::path::Component::Normal(name) = component else {
781 return false;
782 };
783 DIRECTORIES
784 .iter()
785 .any(|directory| name == OsStr::new(directory))
786 })
787}
788
789fn merge_pr_args<'a>(
790 number: &'a str,
791 expected_head: Option<&'a str>,
792 delete_branch: bool,
793) -> Vec<&'a str> {
794 let mut args = vec!["pr", "merge", number, "--squash"];
795 if delete_branch {
796 args.push("--delete-branch");
797 }
798 if let Some(expected_head) = expected_head {
799 args.extend(["--match-head-commit", expected_head]);
800 }
801 args
802}
803
804fn reconcile_pr_creation(
805 branch: &str,
806 created: Result<String>,
807 found: Result<Option<PrRef>>,
808) -> Result<PrRef> {
809 match (created, found) {
810 (_, Ok(Some(pr))) => Ok(pr),
811 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
812 "PR creation reported success but none was found for {branch}"
813 ))),
814 (Err(create), Ok(None)) => Err(spar_err!(
815 "could not open a PR for {branch}. {}",
816 create.last_line()
817 )),
818 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
819 "PR creation reported success for {branch}, but it could not be verified. {}",
820 check.last_line()
821 ))),
822 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
823 "could not open a PR for {branch}. {} The result could not be verified: {}",
824 create.last_line(),
825 check.last_line()
826 ))),
827 }
828}
829
830fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
831 #[derive(Deserialize)]
832 #[serde(rename_all = "camelCase")]
833 struct Row {
834 number: i64,
835 #[serde(default)]
836 url: String,
837 #[serde(default)]
838 title: String,
839 base_ref_name: String,
840 }
841
842 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
843 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
844 })?;
845 Ok(rows
846 .into_iter()
847 .find(|row| row.base_ref_name == base)
848 .map(|row| PrRef {
849 number: row.number,
850 url: row.url,
851 title: row.title,
852 }))
853}
854
855fn signed(body: &str) -> String {
858 if body.contains(COMMENT_MARKER) {
859 return body.to_string();
860 }
861 format!("{body}\n\n{COMMENT_MARKER}")
862}
863
864pub fn is_spar_comment(body: &str) -> bool {
866 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
867}
868
869fn has_exact_comment(comments: &[Value], body: &str) -> bool {
870 comments.iter().any(|comment| {
871 comment
872 .get("body")
873 .and_then(Value::as_str)
874 .is_some_and(|seen| seen == body)
875 })
876}
877
878fn reconcile_comment_post(
879 number: i64,
880 body: &str,
881 post_error: crate::error::SparError,
882 comments: Result<Vec<Value>>,
883) -> Result<()> {
884 match comments {
885 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
886 Ok(_) => Err(post_error),
887 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
888 "could not comment on #{number}. {} The result could not be verified: {}",
889 post_error.last_line(),
890 read_error.last_line()
891 ))),
892 }
893}
894
895fn reconcile_issue_edit(
896 number: i64,
897 wanted: &str,
898 edit_error: crate::error::SparError,
899 observed: Result<String>,
900) -> Result<()> {
901 match observed {
902 Ok(body) if body == wanted => Ok(()),
903 Ok(_) => Err(spar_err!(
904 "could not rewrite the body of #{number}. {}",
905 edit_error.last_line()
906 )),
907 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
908 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
909 edit_error.last_line(),
910 read_error.last_line()
911 ))),
912 }
913}
914
915fn issue_url_has_number(url: &str) -> bool {
916 url.trim()
917 .rsplit('/')
918 .next()
919 .and_then(|tail| tail.parse::<i64>().ok())
920 .is_some_and(|number| number > 0)
921}
922
923fn reconcile_issue_creation(
924 title: &str,
925 created: Result<String>,
926 found: Result<Option<ExistingIssue>>,
927) -> Result<String> {
928 match (created, found) {
929 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
930 (_, Ok(Some(issue))) => Ok(issue.url),
931 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
932 "issue creation reported success but no matching issue was found for {title:?}"
933 ))),
934 (Err(create), Ok(None)) => Err(spar_err!(
935 "could not file issue {title:?}. {}",
936 create.last_line()
937 )),
938 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
939 "issue creation reported success for {title:?}, but it could not be verified. {}",
940 check.last_line()
941 ))),
942 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
943 "could not file issue {title:?}. {} The result could not be verified: {}",
944 create.last_line(),
945 check.last_line()
946 ))),
947 }
948}
949
950fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
951 if output.trim().is_empty() {
952 return Ok(None);
953 }
954 for line in output.lines() {
955 let mut fields = line.split_whitespace();
956 let oid = fields.next().unwrap_or_default();
957 let name = fields.next().unwrap_or_default();
958 if name == remote_ref && !oid.is_empty() {
959 return Ok(Some(oid.to_string()));
960 }
961 }
962 Err(spar_err!(
963 "origin returned an unexpected ref listing for {remote_ref}"
964 ))
965}
966
967fn reconcile_failed_split_push(
968 branch: &str,
969 push_error: crate::error::SparError,
970 local: Result<String>,
971 remote: Result<String>,
972) -> std::result::Result<(), SplitPushError> {
973 let remote_ref = format!("refs/heads/{branch}");
974 match (local, remote) {
975 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
976 Ok(Some(oid)) if oid == local.trim() => Ok(()),
977 Ok(_) => Err(SplitPushError::new(
978 format!(
979 "could not create origin/{branch}. {} The remote branch is absent or points \
980 somewhere else. Nothing was overwritten.",
981 push_error.last_line()
982 ),
983 false,
984 )),
985 Err(check) => Err(SplitPushError::new(
986 format!(
987 "could not confirm whether origin/{branch} was created. {} The remote result \
988 could not be verified: {}",
989 push_error.last_line(),
990 check.last_line()
991 ),
992 true,
993 )),
994 },
995 (local, remote) => {
996 let check = match (local, remote) {
997 (Err(local), Err(remote)) => format!(
998 "the local commit could not be read: {}; origin could not be read: {}",
999 local.last_line(),
1000 remote.last_line()
1001 ),
1002 (Err(local), _) => {
1003 format!("the local commit could not be read: {}", local.last_line())
1004 }
1005 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1006 _ => unreachable!(),
1007 };
1008 Err(SplitPushError::new(
1009 format!(
1010 "could not confirm whether origin/{branch} was created. {} The result could \
1011 not be verified because {check}",
1012 push_error.last_line()
1013 ),
1014 true,
1015 ))
1016 }
1017 }
1018}
1019
1020impl Repo {
1021 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1022 let root =
1023 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1024 let inside = proc::run_str(
1027 &["git", "rev-parse", "--is-inside-work-tree"],
1028 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1029 )
1030 .unwrap_or_default();
1031 if inside.trim() != "true" {
1032 bail!("not a git repository: {}", root.display());
1033 }
1034 let repo = Self {
1035 root,
1036 style: cfg.style.clone(),
1037 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1038 state_store: cfg.loop_cfg.state_store,
1039 followups: cfg.loop_cfg.followups,
1040 drafts: cfg.loop_cfg.drafts,
1041 viewer: OnceLock::new(),
1042 checkpoints: Mutex::new(BTreeMap::new()),
1043 writes: WriteStats::default(),
1044 };
1045 repo.self_exclude();
1046 Ok(repo)
1047 }
1048
1049 fn self_exclude(&self) {
1057 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1058 let git_dir = git_dir.trim();
1059 if git_dir.is_empty() {
1060 return;
1061 }
1062 let path = Path::new(git_dir).join("info").join("exclude");
1063 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1064
1065 let wanted = [
1070 format!("/{WORKTREE_DIR}/"),
1071 format!("/{STATE_DIR}/"),
1072 format!("{RECOVERY_MARKER_PREFIX}*"),
1073 ];
1074 let missing: Vec<&String> = wanted
1075 .iter()
1076 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1077 .collect();
1078 if missing.is_empty() {
1079 return;
1080 }
1081
1082 use std::io::Write;
1083 if let Some(parent) = path.parent() {
1084 let _ = std::fs::create_dir_all(parent);
1085 }
1086 let mut block = String::new();
1087 if !existing.is_empty() && !existing.ends_with('\n') {
1088 block.push('\n');
1089 }
1090 block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1091 for line in missing {
1092 block.push_str(line);
1093 block.push('\n');
1094 }
1095 if let Ok(mut file) = std::fs::OpenOptions::new()
1096 .create(true)
1097 .append(true)
1098 .open(&path)
1099 {
1100 let _ = file.write_all(block.as_bytes());
1101 }
1102 }
1103
1104 pub fn root(&self) -> &Path {
1105 &self.root
1106 }
1107
1108 pub(crate) fn write_summary(&self) -> WriteSummary {
1109 WriteSummary {
1110 attempted: self.writes.attempted.load(Ordering::Relaxed),
1111 failed: self.writes.failed.load(Ordering::Relaxed),
1112 }
1113 }
1114
1115 pub(crate) fn record_write<T, E>(
1116 &self,
1117 result: std::result::Result<T, E>,
1118 ) -> std::result::Result<T, E> {
1119 self.record_write_outcome(result.is_err());
1120 result
1121 }
1122
1123 pub(crate) fn record_failed_write<T, E>(
1124 &self,
1125 result: std::result::Result<T, E>,
1126 ) -> std::result::Result<T, E> {
1127 if result.is_err() {
1128 self.record_write_outcome(true);
1129 }
1130 result
1131 }
1132
1133 fn record_write_outcome(&self, failed: bool) {
1134 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1135 if failed {
1136 self.writes.failed.fetch_add(1, Ordering::Relaxed);
1137 }
1138 }
1139
1140 pub fn clean(&self, text: &str) -> Result<String> {
1146 let out = style::scrub(text, &self.style);
1147 let bad = style::violations(&out, &self.style);
1148 if !bad.is_empty() {
1149 bail!(
1150 "style gate could not clean text ({}): {}",
1151 bad.join(", "),
1152 style::clip(&out, 300)
1153 );
1154 }
1155 Ok(out)
1156 }
1157
1158 pub fn clean_body(&self, text: &str) -> Result<String> {
1160 self.clean(&style::body(text, &self.style))
1161 }
1162
1163 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1165 self.clean(&style::issue_body(text, &self.style))
1166 }
1167
1168 pub fn clean_title(&self, text: &str) -> Result<String> {
1179 Ok(style::title(&self.clean(text)?, &self.style))
1180 }
1181
1182 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1183 let title = self.record_failed_write(self.clean_title(text))?;
1184 if title.trim().is_empty() {
1185 return self.record_failed_write(Err(spar_err!(
1186 "nothing left of the title after cleaning it"
1187 )));
1188 }
1189 Ok(title)
1190 }
1191
1192 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1193 if self.followups == Followups::Issues {
1194 self.clean_nonempty_title_for_write(text)
1195 } else {
1196 self.clean_title(text)
1197 }
1198 }
1199
1200 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1203 ExecOpts::new()
1204 .cwd(cwd.unwrap_or(&self.root))
1205 .check(check)
1206 .timeout_secs(600)
1207 }
1208
1209 pub fn git(&self, args: &[&str]) -> Result<String> {
1210 self.git_at(None, args)
1211 }
1212
1213 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1214 let argv = git_without_maintenance_argv(args);
1215 proc::run(&argv, &self.git_opts(cwd, true))
1216 }
1217
1218 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1224 let argv = git_without_automation_argv(args);
1225 proc::run(
1226 &argv,
1227 &self.git_opts(Some(cwd), true).stop_descendants(true),
1228 )
1229 }
1230
1231 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1232 let argv = git_without_automation_argv(args);
1233 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1234 .map(|output| output.ok())
1235 }
1236
1237 pub fn git_try(&self, args: &[&str]) -> String {
1239 self.git_try_at(None, args)
1240 }
1241
1242 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1243 let argv = git_without_maintenance_argv(args);
1244 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1245 }
1246
1247 pub fn default_branch(&self, configured: &str) -> String {
1250 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1251 match refname.trim().rsplit('/').next() {
1252 Some(name) if !name.is_empty() => name.to_string(),
1253 _ => configured.to_string(),
1254 }
1255 }
1256
1257 pub fn branch_for_issue(&self, issue: i64) -> String {
1265 format!("{}issue-{issue}", self.branch_prefix)
1266 }
1267
1268 pub fn branch_for_pr(&self, number: i64) -> String {
1269 format!("{}pr-{number}", self.branch_prefix)
1270 }
1271
1272 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1281 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1282 }
1283
1284 fn ledger_path(&self) -> PathBuf {
1285 self.root.join(STATE_DIR).join("branches.json")
1286 }
1287
1288 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1289 std::fs::read_to_string(self.ledger_path())
1290 .ok()
1291 .and_then(|text| serde_json::from_str(&text).ok())
1292 .unwrap_or_default()
1293 }
1294
1295 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1296 let mut data = self.known_branches();
1297 data.insert(
1298 branch.to_string(),
1299 BranchRecord {
1300 kind: kind.to_string(),
1301 number,
1302 },
1303 );
1304 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1305 logdim!("could not record branch {branch}: {e}");
1306 }
1307 }
1308
1309 pub fn forget_branch(&self, branch: &str) {
1310 let mut data = self.known_branches();
1311 if data.remove(branch).is_none() {
1312 return;
1313 }
1314 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1315 logdim!("could not update the branch record: {e}");
1316 }
1317 }
1318
1319 fn worktree_path(&self, name: &str) -> PathBuf {
1322 self.root.join(WORKTREE_DIR).join(name)
1323 }
1324
1325 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1327 let branch = self.branch_for_issue(issue);
1328 let path = self.worktree_path(&format!("issue-{issue}"));
1329
1330 self.refuse_issue_branch_rebuild(issue, base)?;
1331 self.refuse_dirty_worktree(
1332 &path,
1333 &format!("worktree for issue #{issue}"),
1334 Disposable::BuildOutput,
1335 )?;
1336
1337 if !self.branch_deletion_is_safe(&branch)? {
1338 bail!(
1339 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1340 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1341 before retrying."
1342 );
1343 }
1344
1345 if !self.remove_worktree_at(&path)? {
1346 bail!(
1347 "the existing worktree for issue #{issue} could not be removed safely. Its \
1348 branch was kept."
1349 );
1350 }
1351 if !self.delete_branch_if_safe(&branch)? {
1352 bail!(
1353 "the existing branch {branch} changed or remained checked out while its \
1354 worktree was being rebuilt. It was kept."
1355 );
1356 }
1357
1358 if let Some(parent) = path.parent() {
1359 std::fs::create_dir_all(parent)
1360 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1361 }
1362
1363 let path_str = path.display().to_string();
1364 let remote_start = format!("origin/{base}");
1365 let created = self
1366 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1367 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1368
1369 created.map_err(|e| {
1372 spar_err!(
1373 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1374 and does `origin` exist?",
1375 e.last_line()
1376 )
1377 })?;
1378 self.record_branch(&branch, "issue", issue);
1379 Ok((path, branch))
1380 }
1381
1382 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1389 let branch = self.branch_for_issue(issue);
1390 let base_remote_ref = format!("refs/heads/{base}");
1391 let base_tracking_ref = format!("refs/remotes/origin/{base}");
1392 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1393 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1394 .map_err(|e| {
1395 spar_err!(
1396 "could not refresh origin/{base} before checking issue #{issue}: {}",
1397 e.last_line()
1398 )
1399 })?;
1400
1401 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1402 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1403 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1404 bail!(
1405 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1406 pull request accounts for them. Rebuilding it would force push over that \
1407 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1408 it, or delete it with `git push origin --delete {branch}` if the remote \
1409 branch is no longer needed."
1410 );
1411 }
1412 }
1413
1414 let local_ref = format!("refs/heads/{branch}");
1415 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1416 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1417 let recorded_pr = self
1418 .known_branches()
1419 .get(&branch)
1420 .is_some_and(|record| record.kind == "pr");
1421 let preserved = ahead == 0
1422 || if recorded_pr {
1423 self.local_branch_is_preserved(&branch)?
1424 } else {
1425 self.pull_request_holds(&branch, &local_ref, base)
1426 };
1427 if !preserved {
1428 let listed = self
1429 .commit_lines(&self.root, &local_ref, base)
1430 .iter()
1431 .map(|line| format!(" {line}"))
1432 .collect::<Vec<_>>()
1433 .join("\n");
1434 bail!(
1435 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1436 no pull request preserves them. Rebuilding it would delete the only copy.\n\
1437 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1438 it, or delete it with `git branch -D {branch}` if it is stale."
1439 );
1440 }
1441 }
1442 Ok(())
1443 }
1444
1445 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1446 let live_ref = format!("refs/heads/{branch}");
1447 let tracking_ref = format!("refs/remotes/origin/{branch}");
1448 let listed = self
1449 .git(&["ls-remote", "--heads", "origin", &live_ref])
1450 .map_err(|e| {
1451 spar_err!(
1452 "could not verify whether origin/{branch} still exists: {}",
1453 e.last_line()
1454 )
1455 })?;
1456
1457 if remote_head_oid(&listed, &live_ref)?.is_some() {
1458 let refspec = format!("+{live_ref}:{tracking_ref}");
1459 self.git(&["fetch", "--no-tags", "origin", &refspec])
1460 .map_err(|e| {
1461 spar_err!(
1462 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1463 e.last_line()
1464 )
1465 })?;
1466 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1467 bail!("origin/{branch} was fetched but its tracking ref is missing");
1468 }
1469 return Ok(Some(tracking_ref));
1470 }
1471
1472 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1473 return Ok(None);
1474 }
1475 let expected = self
1476 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1477 .trim()
1478 .to_string();
1479 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1480 .map_err(|e| {
1481 spar_err!(
1482 "could not discard stale origin/{branch} tracking ref safely: {}",
1483 e.last_line()
1484 )
1485 })?;
1486 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1487 bail!(
1488 "origin/{branch} changed while its stale tracking ref was being removed. It was \
1489 kept."
1490 );
1491 }
1492 Ok(None)
1493 }
1494
1495 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1504 self.prs_for_branch(branch)
1505 .iter()
1506 .any(|pr| self.pr_head_holds(pr.number, refname, base))
1507 }
1508
1509 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1510 let head = format!("refs/spar/pr-head/{number}");
1511 let refspec = format!("+refs/pull/{number}/head:{head}");
1512 if self.git(&["fetch", "origin", &refspec]).is_err() {
1513 return false;
1514 }
1515 let held = self.commits_held_by(refname, base, &head);
1516 self.git_try(&["update-ref", "-d", &head]);
1517 held
1518 }
1519
1520 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1521 let argv = vec![
1522 "git".to_string(),
1523 "merge-base".to_string(),
1524 "--is-ancestor".to_string(),
1525 older.to_string(),
1526 newer.to_string(),
1527 ];
1528 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1529 match out.code {
1530 0 => Ok(true),
1531 1 => Ok(false),
1532 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1533 }
1534 }
1535
1536 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1537 let head = format!("refs/spar/pr-head/{number}");
1538 let refspec = format!("+refs/pull/{number}/head:{head}");
1539 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1540 spar_err!(
1541 "could not verify the immutable head of PR #{number}: {}",
1542 e.last_line()
1543 )
1544 })?;
1545 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1546 self.git_try(&["update-ref", "-d", &head]);
1547 held
1548 }
1549
1550 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1551 let text = self.gh(&[
1552 "pr",
1553 "list",
1554 "--head",
1555 branch,
1556 "--state",
1557 "all",
1558 "--json",
1559 "number,url,title",
1560 ])?;
1561 serde_json::from_str(text.trim())
1562 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1563 }
1564
1565 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1566 let branch_ref = format!("refs/heads/{branch}");
1567 if record.kind == "pr" {
1568 return self.pr_head_contains_checked(record.number, &branch_ref);
1569 }
1570 let prs = self.branch_prs_checked(branch)?;
1571 if prs.is_empty() {
1572 return Ok(false);
1573 }
1574 for pr in prs {
1575 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1576 return Ok(true);
1577 }
1578 }
1579 Ok(false)
1580 }
1581
1582 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1583 let local_ref = format!("refs/heads/{branch}");
1584 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1585 return Ok(true);
1586 }
1587 let oid = self
1588 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1589 .trim()
1590 .to_string();
1591 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
1592 if !durable_tip {
1593 let remote_ref = format!("refs/heads/{branch}");
1594 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
1595 durable_tip = remote.lines().any(|line| {
1596 line.split_whitespace()
1597 .next()
1598 .is_some_and(|remote_oid| remote_oid == oid)
1599 });
1600 }
1601 if !durable_tip {
1602 if let Some(record) = self.known_branches().get(branch) {
1603 durable_tip = self.branch_is_preserved_checked(branch, record)?;
1604 }
1605 }
1606 if !durable_tip {
1607 return Ok(false);
1608 }
1609 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1610 }
1611
1612 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
1616 let local_ref = format!("refs/heads/{branch}");
1617 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1618 return Ok(true);
1619 }
1620 let expected = self
1621 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1622 .trim()
1623 .to_string();
1624 if !self.branch_deletion_is_safe(branch)? {
1625 return Ok(false);
1626 }
1627 let checked_out = self
1628 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
1629 .lines()
1630 .any(|line| line == format!("branch {local_ref}"));
1631 if checked_out {
1632 return Ok(false);
1633 }
1634 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1635 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1636 }
1637
1638 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
1639 let local_ref = review_ref(number);
1640 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1641 return Ok(true);
1642 }
1643 let oid = self
1644 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1645 .trim()
1646 .to_string();
1647 if !self.pr_head_contains_checked(number, &local_ref)? {
1648 return Ok(false);
1649 }
1650 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1651 }
1652
1653 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
1654 let local_ref = review_ref(number);
1655 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1656 return Ok(true);
1657 }
1658 let expected = self
1659 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1660 .trim()
1661 .to_string();
1662 if !self.review_ref_deletion_is_safe(number)? {
1663 return Ok(false);
1664 }
1665 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1666 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1667 }
1668
1669 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
1673 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
1674 self.git_try(&["rev-list", "--count", &range, "--not", other])
1675 .trim()
1676 == "0"
1677 }
1678
1679 pub fn worktree_remove(&self, issue: i64) -> bool {
1680 let path = self.worktree_path(&format!("issue-{issue}"));
1681 match self.remove_worktree_at(&path) {
1682 Ok(removed) => removed,
1683 Err(error) => {
1684 logdim!(
1685 "kept {} because removal did not reach a confirmed quiet point: {}",
1686 path.display(),
1687 error.last_line()
1688 );
1689 false
1690 }
1691 }
1692 }
1693
1694 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
1699 let wanted = std::fs::canonicalize(path)
1700 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
1701 if wanted != path {
1706 return Ok(false);
1707 }
1708 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
1709 let raw = PathBuf::from(value.trim());
1710 let joined = if raw.is_absolute() {
1711 raw
1712 } else {
1713 cwd.join(raw)
1714 };
1715 std::fs::canonicalize(&joined)
1716 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
1717 };
1718 let expected =
1719 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
1720 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
1721 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
1722 let expected = resolve(&self.root, &expected)?;
1723 let actual = resolve(path, &actual)?;
1724 let top = resolve(path, &top)?;
1725 Ok(expected == actual && top == wanted)
1726 }
1727
1728 fn remove_worktree_at_with_force(
1734 &self,
1735 path: &Path,
1736 force: bool,
1737 disposable: Disposable,
1738 ) -> Result<bool> {
1739 let existed = path.exists();
1740 if path.exists() {
1741 match self.worktree_belongs_to_repo(path) {
1742 Ok(true) => {}
1743 Ok(false) => {
1744 logdim!(
1745 "kept {} because it is not a worktree owned by this repository",
1746 path.display()
1747 );
1748 return Ok(false);
1749 }
1750 Err(e) => {
1751 logdim!(
1752 "kept {} because its worktree ownership could not be verified: {}",
1753 path.display(),
1754 e.last_line()
1755 );
1756 return Ok(false);
1757 }
1758 }
1759 if !force {
1760 match self.has_recoverable_work(path, disposable) {
1761 Ok(true) => {
1762 logdim!(
1763 "kept {} because it contains recoverable files or repository state",
1764 path.display()
1765 );
1766 return Ok(false);
1767 }
1768 Err(e) => {
1769 logdim!(
1770 "kept {} because its recoverable state could not be checked: {}",
1771 path.display(),
1772 e.last_line()
1773 );
1774 return Ok(false);
1775 }
1776 Ok(false) => {}
1777 }
1778 }
1779 }
1780 let path_str = path.display().to_string();
1786 let command_ok = if force || disposable == Disposable::ReviewOutput {
1787 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
1788 } else {
1789 self.git_try_without_automation(&["worktree", "remove", &path_str])?
1790 };
1791 Ok((command_ok || !existed) && !path.exists())
1792 }
1793
1794 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
1795 self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
1796 }
1797
1798 fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
1800 self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
1801 }
1802
1803 fn remove_worktree_at_force(&self, path: &Path) -> bool {
1805 match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
1806 Ok(removed) => removed,
1807 Err(error) => {
1808 logdim!(
1809 "kept {} because removal did not reach a confirmed quiet point: {}",
1810 path.display(),
1811 error.last_line()
1812 );
1813 false
1814 }
1815 }
1816 }
1817
1818 fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
1823 if path.exists() && !self.worktree_belongs_to_repo(path)? {
1824 bail!(
1825 "{} is not a worktree owned by this repository, so it was kept",
1826 path.display()
1827 );
1828 }
1829 if path.exists() && self.has_recoverable_work(path, disposable)? {
1830 bail!(
1831 "the verified worktree at {} contains recoverable files or repository state. It \
1832 was kept.",
1833 path.display()
1834 );
1835 }
1836 let path_str = path.display().to_string();
1837 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
1838 .map_err(|e| {
1839 e.with_message(format!(
1840 "could not remove the verified worktree at {}: {}. It was kept.",
1841 path.display(),
1842 e.last_line()
1843 ))
1844 })?;
1845 Ok(!path.exists())
1846 }
1847
1848 fn refuse_dirty_worktree(
1849 &self,
1850 path: &Path,
1851 label: &str,
1852 disposable: Disposable,
1853 ) -> Result<()> {
1854 if !path.is_dir() {
1855 return Ok(());
1856 }
1857 let has_files = std::fs::read_dir(path)
1858 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
1859 .next()
1860 .is_some();
1861 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
1862 spar_err!(
1863 "could not verify whether the existing {label} at {} belongs to this repository, \
1864 so it was kept: {}",
1865 path.display(),
1866 e.last_line()
1867 )
1868 })?;
1869 if !owned {
1870 if has_files {
1871 bail!(
1872 "the existing {label} at {} is not a worktree owned by \
1873 this repository. Refusing to remove it.",
1874 path.display()
1875 );
1876 }
1877 return Ok(());
1878 }
1879 if !path.join(".git").exists() {
1880 if has_files {
1881 bail!(
1882 "the existing {label} at {} is not a readable Git worktree and is not empty. \
1883 Refusing to remove it.",
1884 path.display()
1885 );
1886 }
1887 return Ok(());
1888 }
1889 let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
1890 spar_err!(
1891 "could not verify whether the existing {label} at {} is clean, so it was kept: \
1892 {}",
1893 path.display(),
1894 e.last_line()
1895 )
1896 })?;
1897 if dirty {
1898 bail!(
1899 "the existing {label} contains uncommitted changes or ignored files at {}. \
1900 Rebuilding it would delete those files.\nCommit or recover them before running this \
1901 command again, or use `spar clean --all` if they are not needed.",
1902 path.display()
1903 );
1904 }
1905 Ok(())
1906 }
1907
1908 pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
1910 let head = pr.head_ref_name.clone();
1911 if head.trim().is_empty() {
1912 bail!("PR #{} has no head branch to check out", pr.number);
1913 }
1914 let path = self.worktree_path(&format!("pr-{}", pr.number));
1915 let local = self.branch_for_pr(pr.number);
1916
1917 self.git(&["fetch", "origin", &head]).map_err(|e| {
1918 spar_err!(
1919 "could not fetch the branch behind PR #{}: {}",
1920 pr.number,
1921 e.last_line()
1922 )
1923 })?;
1924 let start = format!("origin/{head}");
1925 let start_ref = format!("refs/remotes/origin/{head}");
1926 let local_ref = format!("refs/heads/{local}");
1927 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1928 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
1929 if unpushed > 0 {
1930 bail!(
1931 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
1932 on {start}. Rebuilding it would delete their branch.\nInspect the worktree at \
1933 {} and push or recover those commits before running this command again.",
1934 pr.number,
1935 path.display()
1936 );
1937 }
1938 }
1939 self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
1940 self.refuse_dirty_worktree(
1941 &path,
1942 &format!("worktree for PR #{}", pr.number),
1943 Disposable::BuildOutput,
1944 )?;
1945 if !self.branch_deletion_is_safe(&local)? {
1946 bail!(
1947 "the existing branch {local} has a tip or reflog-only commit that no surviving \
1948 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1949 before retrying."
1950 );
1951 }
1952 if !self.remove_worktree_at(&path)? {
1953 bail!(
1954 "the existing worktree for PR #{} could not be removed safely. Its branch was \
1955 kept.",
1956 pr.number
1957 );
1958 }
1959 if !self.delete_branch_if_safe(&local)? {
1960 bail!(
1961 "the existing branch {local} changed or remained checked out while the PR \
1962 worktree was being rebuilt. It was kept."
1963 );
1964 }
1965
1966 let path_str = path.display().to_string();
1967 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
1968 self.record_branch(&local, "pr", pr.number);
1969 Ok((path, head))
1970 }
1971
1972 fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
1975 if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
1976 return Ok(());
1977 }
1978 let current =
1979 self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
1980 if current.trim() != branch
1981 || self.has_uncommitted_changes(path)?
1982 || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
1983 {
1984 return Ok(());
1985 }
1986 static NEXT: AtomicUsize = AtomicUsize::new(0);
1987 let (name, retained) = loop {
1988 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
1989 let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
1990 let retained = self.worktree_path(&name);
1991 if !retained
1992 .try_exists()
1993 .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
1994 && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
1995 {
1996 break (name, retained);
1997 }
1998 };
1999 self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2002 if let Err(error) = self.git_at_without_automation(
2003 &self.root,
2004 &[
2005 "worktree",
2006 "move",
2007 &path.display().to_string(),
2008 &retained.display().to_string(),
2009 ],
2010 ) {
2011 let rollback =
2014 self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2015 let detail = rollback
2016 .err()
2017 .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2018 .unwrap_or_default();
2019 bail!(
2020 "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2021 retained.display(),
2022 error.last_line()
2023 );
2024 }
2025 logdim!(
2026 "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2027 retained.display()
2028 );
2029 Ok(())
2030 }
2031
2032 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2042 let path = self.worktree_path(&format!("review-{number}"));
2043 let local_ref = review_ref(number);
2044 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2045
2046 self.refuse_review_worktree_changes(number)?;
2047
2048 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2049 spar_err!(
2050 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2051 every pull request, so this usually means the number is wrong or `origin` does \
2052 not point at the repository the PR is on.",
2053 e.last_line()
2054 )
2055 })?;
2056
2057 if let Some(parent) = path.parent() {
2058 std::fs::create_dir_all(parent)
2059 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2060 }
2061 if !self.remove_review_worktree_at(&path)? {
2062 bail!(
2063 "the existing review worktree for PR #{number} could not be removed safely. Its \
2064 reference was kept."
2065 );
2066 }
2067 let path_str = path.display().to_string();
2068 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2069 Ok(path)
2070 }
2071
2072 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2073 let path = self.worktree_path(&format!("review-{number}"));
2074 if !path.is_dir() {
2075 return Ok(());
2076 }
2077 let local_ref = review_ref(number);
2078 if !self.worktree_belongs_to_repo(&path)? {
2079 return Ok(());
2080 }
2081 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2082 bail!(
2083 "the existing review worktree for PR #{number} has no recorded head at \
2084 {local_ref}. Refusing to rebuild {}.",
2085 path.display()
2086 );
2087 }
2088 let worktree_head = self.head_oid_checked(&path)?;
2089 let recorded_head = self
2090 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2091 .trim()
2092 .to_string();
2093 if worktree_head != recorded_head {
2094 bail!(
2095 "the existing review worktree for PR #{number} has a local commit that is not on \
2096 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2097 {} before retrying.",
2098 path.display()
2099 );
2100 }
2101 self.report_recovery_markers(&path);
2102 self.refuse_dirty_worktree(
2103 &path,
2104 &format!("review worktree for PR #{number}"),
2105 Disposable::ReviewOutput,
2106 )?;
2107 Ok(())
2108 }
2109
2110 fn report_recovery_markers(&self, path: &Path) {
2118 let Ok(entries) = std::fs::read_dir(path) else {
2119 return;
2120 };
2121 for entry in entries.flatten() {
2122 let name = entry.file_name();
2123 let Some(name) = name.to_str() else { continue };
2124 if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2125 continue;
2126 }
2127 match std::fs::read_to_string(entry.path()) {
2128 Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2129 Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2130 }
2131 }
2132 }
2133
2134 pub fn worktree_for_split(
2147 &self,
2148 parent: i64,
2149 index: usize,
2150 start: &str,
2151 ) -> Result<(PathBuf, String)> {
2152 let slot = self.free_split_slot(parent, index)?;
2153 let branch = format!("{}{slot}", self.branch_prefix);
2154 let path = self.worktree_path(&slot);
2155
2156 if let Some(dir) = path.parent() {
2157 std::fs::create_dir_all(dir)
2158 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2159 }
2160 self.refuse_dirty_worktree(
2161 &path,
2162 &format!("worktree for part {index} of PR #{parent}"),
2163 Disposable::BuildOutput,
2164 )?;
2165 if !self.remove_worktree_at(&path)? {
2169 bail!(
2170 "the existing worktree for part {index} of PR #{parent} could not be removed \
2171 safely. No branch was created."
2172 );
2173 }
2174
2175 let path_str = path.display().to_string();
2176 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2177 .map_err(|e| {
2178 spar_err!(
2179 "could not create a worktree for part {index} of #{parent}. {}",
2180 e.last_line()
2181 )
2182 })?;
2183 self.record_branch(&branch, "split", parent);
2186 Ok((path, branch))
2187 }
2188
2189 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2196 for attempt in 1..=SPLIT_SLOTS {
2197 let slot = split_slot(parent, index, attempt);
2198 let branch = format!("{}{slot}", self.branch_prefix);
2199 self.git_try(&["fetch", "origin", &branch]);
2200 if !self.rev_exists(&self.root, &branch)
2201 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2202 {
2203 return Ok(slot);
2204 }
2205 }
2206 bail!(
2207 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2208 names are all taken. Inspect the existing branches and child pull requests. Finish \
2209 recording the earlier split, or remove every retained local worktree and branch, \
2210 child pull request, and remote split branch before starting over.",
2211 self.branch_for_split(parent, index)
2212 )
2213 }
2214
2215 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2221 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2222 Ok(!self
2223 .git(&["ls-remote", "--heads", "origin", &pattern])?
2224 .trim()
2225 .is_empty())
2226 }
2227
2228 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2235 match self.branch_deletion_is_safe(branch) {
2236 Ok(true) => {}
2237 Ok(false) => {
2238 logdim!(
2239 "kept {branch} and {} because no surviving ref preserves its tip",
2240 dir.display()
2241 );
2242 return;
2243 }
2244 Err(error) => {
2245 logdim!(
2246 "kept {branch} and {} because preservation could not be verified: {}",
2247 dir.display(),
2248 error.last_line()
2249 );
2250 return;
2251 }
2252 }
2253 match self.remove_worktree_at(dir) {
2254 Ok(true) => match self.delete_branch_if_safe(branch) {
2255 Ok(true) => self.forget_branch(branch),
2256 Ok(false) => {
2257 logdim!("kept {branch} because its tip or reflog changed before deletion")
2258 }
2259 Err(error) => logdim!(
2260 "kept {branch} because deletion safety could not be rechecked: {}",
2261 error.last_line()
2262 ),
2263 },
2264 Ok(false) => {}
2265 Err(error) => logdim!(
2266 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2267 dir.display(),
2268 error.last_line()
2269 ),
2270 }
2271 }
2272
2273 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2279 let record = self.known_branches().get(branch).cloned();
2280 if record.is_none_or(|record| record.kind != "split") {
2281 logdim!("kept {branch} because no split branch record proves ownership");
2282 return false;
2283 }
2284 let local_ref = format!("refs/heads/{branch}");
2285 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2286 Ok(value) => value.trim().to_string(),
2287 Err(error) => {
2288 logdim!(
2289 "kept {branch} because its tip could not be checked: {}",
2290 error.last_line()
2291 );
2292 return false;
2293 }
2294 };
2295 if expected != disposable_head {
2296 logdim!("kept {branch} because it moved beyond the disposable slice");
2297 return false;
2298 }
2299 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2300 Ok(true) => {}
2301 Ok(false) => {
2302 logdim!(
2303 "kept {branch} because its reflog contains work outside the disposable slice"
2304 );
2305 return false;
2306 }
2307 Err(error) => {
2308 logdim!(
2309 "kept {branch} because its reflog could not be checked: {}",
2310 error.last_line()
2311 );
2312 return false;
2313 }
2314 }
2315 match self.head_oid_checked(dir) {
2316 Ok(head) if head == disposable_head => {}
2317 Ok(_) => {
2318 logdim!(
2319 "kept {branch} and {} because the worktree moved beyond the disposable slice",
2320 dir.display()
2321 );
2322 return false;
2323 }
2324 Err(error) => {
2325 logdim!(
2326 "kept {branch} and {} because its head could not be checked: {}",
2327 dir.display(),
2328 error.last_line()
2329 );
2330 return false;
2331 }
2332 }
2333 match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2334 Ok(true) => {}
2335 Ok(false) => return false,
2336 Err(error) => {
2337 logdim!(
2338 "kept {branch} and {} because the disposable slice could not be verified: {}",
2339 dir.display(),
2340 error.last_line()
2341 );
2342 return false;
2343 }
2344 }
2345 if let Err(error) =
2346 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2347 {
2348 logdim!(
2349 "kept {branch} because its exact disposable tip could not be deleted: {}",
2350 error.last_line()
2351 );
2352 return false;
2353 }
2354 match self.exact_ref_exists_checked(&self.root, &local_ref) {
2355 Ok(false) => {
2356 self.forget_branch(branch);
2357 true
2358 }
2359 Ok(true) => {
2360 logdim!("kept {branch} because its ref still exists after deletion");
2361 false
2362 }
2363 Err(error) => {
2364 logdim!(
2365 "kept the branch record for {branch} because deletion could not be verified: {}",
2366 error.last_line()
2367 );
2368 false
2369 }
2370 }
2371 }
2372
2373 pub fn release_review_worktree(&self, number: i64) {
2374 let path = self.worktree_path(&format!("review-{number}"));
2375 match self.review_ref_deletion_is_safe(number) {
2376 Ok(true) => {}
2377 Ok(false) => {
2378 logdim!(
2379 "kept {} because no surviving ref preserves its review history",
2380 path.display()
2381 );
2382 return;
2383 }
2384 Err(error) => {
2385 logdim!(
2386 "kept {} because review history could not be verified: {}",
2387 path.display(),
2388 error.last_line()
2389 );
2390 return;
2391 }
2392 }
2393 match self.remove_worktree_at(&path) {
2394 Ok(true) => match self.delete_review_ref_if_safe(number) {
2395 Ok(true) => {}
2396 Ok(false) => logdim!(
2397 "kept {} because its review history changed before deletion",
2398 review_ref(number)
2399 ),
2400 Err(error) => logdim!(
2401 "kept {} because deletion safety could not be rechecked: {}",
2402 review_ref(number),
2403 error.last_line()
2404 ),
2405 },
2406 Ok(false) => {}
2407 Err(error) => logdim!(
2408 "kept {} because removal did not reach a confirmed quiet point: {}",
2409 path.display(),
2410 error.last_line()
2411 ),
2412 }
2413 }
2414
2415 pub(crate) fn release_review_worktree_checked(
2418 &self,
2419 number: i64,
2420 checkpoint: &WorktreeCheckpoint,
2421 ) -> Result<()> {
2422 let path = self.worktree_path(&format!("review-{number}"));
2423 self.require_unchanged_worktree(
2424 &path,
2425 checkpoint,
2426 &format!("review worktree for PR #{number}"),
2427 )?;
2428 if !self.review_ref_deletion_is_safe(number)? {
2429 bail!(
2430 "the review reference for PR #{number} has reflog-only recovery history. The \
2431 worktree and reference were kept."
2432 );
2433 }
2434 if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2435 bail!(
2436 "the verified review worktree at {} could not be removed, so its reference was \
2437 kept",
2438 path.display()
2439 );
2440 }
2441 if !self.delete_review_ref_if_safe(number)? {
2442 bail!(
2443 "the review reference for PR #{number} changed before deletion. The reference was \
2444 kept."
2445 );
2446 }
2447 Ok(())
2448 }
2449
2450 pub fn release_pr_worktree(&self, number: i64) -> bool {
2451 let path = self.worktree_path(&format!("pr-{number}"));
2452 let local = self.branch_for_pr(number);
2453 match self.branch_deletion_is_safe(&local) {
2454 Ok(true) => {}
2455 Ok(false) => {
2456 logdim!(
2457 "kept {local} and {} because no surviving ref preserves its tip",
2458 path.display()
2459 );
2460 return false;
2461 }
2462 Err(error) => {
2463 logdim!(
2464 "kept {local} and {} because preservation could not be verified: {}",
2465 path.display(),
2466 error.last_line()
2467 );
2468 return false;
2469 }
2470 }
2471 match self.remove_worktree_at(&path) {
2472 Ok(true) => match self.delete_branch_if_safe(&local) {
2473 Ok(true) => {
2474 self.forget_branch(&local);
2475 true
2476 }
2477 Ok(false) => {
2478 logdim!("kept {local} because its tip or reflog changed before deletion");
2479 false
2480 }
2481 Err(error) => {
2482 logdim!(
2483 "kept {local} because deletion safety could not be rechecked: {}",
2484 error.last_line()
2485 );
2486 false
2487 }
2488 },
2489 Ok(false) => false,
2490 Err(error) => {
2491 logdim!(
2492 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
2493 path.display(),
2494 error.last_line()
2495 );
2496 false
2497 }
2498 }
2499 }
2500
2501 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
2512 let remote = format!("origin/{base}");
2513 if self.rev_exists(cwd, &remote) {
2514 return remote;
2515 }
2516 if self.rev_exists(cwd, base) {
2517 logdim!("origin/{base} does not resolve, comparing against local {base}");
2518 return base.to_string();
2519 }
2520 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
2521 remote
2522 }
2523
2524 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
2525 let spec = format!("{refname}^{{commit}}");
2526 !self
2527 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
2528 .trim()
2529 .is_empty()
2530 }
2531
2532 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2533 let range = format!("{}..HEAD", self.base_ref(cwd, base));
2534 !self
2535 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2536 .trim()
2537 .is_empty()
2538 }
2539
2540 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2541 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2542 Ok(found.lines().any(|line| line.trim() == refname))
2543 }
2544
2545 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2546 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2547 count.trim().parse::<usize>().map_err(|e| {
2548 spar_err!(
2549 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2550 count.trim()
2551 )
2552 })
2553 }
2554
2555 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2556 let remote = format!("refs/remotes/origin/{base}");
2557 if self.exact_ref_exists_checked(cwd, &remote)? {
2558 return Ok(remote);
2559 }
2560 let local = format!("refs/heads/{base}");
2561 if self.exact_ref_exists_checked(cwd, &local)? {
2562 return Ok(local);
2563 }
2564 bail!("neither origin/{base} nor local branch {base} resolves")
2565 }
2566
2567 pub(crate) fn commit_count_checked(
2568 &self,
2569 cwd: &Path,
2570 refname: &str,
2571 base: &str,
2572 ) -> Result<usize> {
2573 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
2574 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
2575 count.trim().parse::<usize>().map_err(|e| {
2576 spar_err!(
2577 "git returned an invalid commit count for {range}: {:?} ({e})",
2578 count.trim()
2579 )
2580 })
2581 }
2582
2583 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
2584 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
2585 }
2586
2587 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
2588 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
2589 let head = head.trim().to_string();
2590 if head.is_empty() {
2591 bail!("git returned an empty HEAD for {}", cwd.display());
2592 }
2593 Ok(head)
2594 }
2595
2596 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
2601 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
2602 self.local_branch_is_preserved(branch.trim())
2603 }
2604
2605 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
2608 let known = self.known_branches();
2609 let Some(record) = known.get(branch) else {
2610 return Ok(false);
2611 };
2612 self.branch_is_preserved_checked(branch, record)
2613 }
2614
2615 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
2617 has_uncommitted_work(cwd)
2618 }
2619
2620 fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
2623 repository_has_recoverable_work_with(cwd, true, disposable)
2624 }
2625
2626 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2628 let attributes = attribute_state(cwd)?;
2629 Ok(WorktreeBaseline {
2630 attributes,
2631 ignored_untracked: ignored_untracked_state(cwd)?,
2632 git_state: safe_git_state(cwd)?,
2633 })
2634 }
2635
2636 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2639 let attributes = attribute_state(cwd)?;
2640 Ok(WorktreeCheckpoint {
2641 path: std::fs::canonicalize(cwd)
2642 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2643 attributes,
2644 git_state: safe_git_state(cwd)?,
2645 ignored_untracked: ignored_untracked_state(cwd)?,
2646 })
2647 }
2648
2649 pub(crate) fn require_unchanged_worktree(
2652 &self,
2653 cwd: &Path,
2654 checkpoint: &WorktreeCheckpoint,
2655 label: &str,
2656 ) -> Result<()> {
2657 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2658 crate::error::SparError::uncertain_write(format!(
2659 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2660 cwd.display()
2661 ))
2662 })?;
2663 if resolved != checkpoint.path {
2664 return Err(uncertain_worktree_change(
2665 cwd,
2666 format!(
2667 "the {label} moved from {} to {} during inspection. It was kept.",
2668 checkpoint.path.display(),
2669 resolved.display()
2670 ),
2671 ));
2672 }
2673 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
2674 uncertain_worktree_change(
2675 cwd,
2676 format!(
2677 "could not verify attribute files in the {label} at {}: {}. It was kept.",
2678 cwd.display(),
2679 e.last_line()
2680 ),
2681 )
2682 })?;
2683 if attributes != checkpoint.attributes {
2684 return Err(uncertain_worktree_change(
2685 cwd,
2686 format!(
2687 "attribute files in the {label} at {} changed during inspection. It was \
2688 kept for recovery.",
2689 cwd.display()
2690 ),
2691 ));
2692 }
2693 let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
2694 uncertain_worktree_change(
2695 cwd,
2696 format!(
2697 "could not verify the Git state of the {label} at {}: {}. It was kept.",
2698 cwd.display(),
2699 e.last_line()
2700 ),
2701 )
2702 })?;
2703 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
2704 uncertain_worktree_change(
2705 cwd,
2706 format!(
2707 "could not verify untracked files in the {label} at {}: {}. It was kept.",
2708 cwd.display(),
2709 e.last_line()
2710 ),
2711 )
2712 })?;
2713 let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
2714 note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
2718 let tracked = checkpoint.git_state.describe_difference(&git_state);
2719 let untracked = (!leavings.condemning.is_empty()).then(|| {
2720 format!(
2721 "untracked file(s) changed: {}",
2722 list_paths(&leavings.condemning)
2723 )
2724 });
2725 let Some(reason) = join_reasons(tracked, untracked) else {
2726 return Ok(());
2727 };
2728 Err(uncertain_worktree_change(
2729 cwd,
2730 format!(
2731 "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
2732 for recovery.",
2733 cwd.display()
2734 ),
2735 ))
2736 }
2737
2738 pub(crate) fn refuse_new_ignored_files(
2742 &self,
2743 cwd: &Path,
2744 baseline: &WorktreeBaseline,
2745 ) -> Result<()> {
2746 self.allow_ignored_files(cwd, baseline).map(drop)
2747 }
2748
2749 pub(crate) fn refuse_failed_edit_leavings(
2751 &self,
2752 cwd: &Path,
2753 baseline: &WorktreeBaseline,
2754 ) -> Result<()> {
2755 self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
2756 .map(drop)
2757 }
2758
2759 pub(crate) fn refuse_read_only_leavings(
2761 &self,
2762 cwd: &Path,
2763 baseline: &WorktreeBaseline,
2764 ) -> Result<()> {
2765 self.refuse_changed_attributes(cwd, baseline)?;
2766 let after = ignored_untracked_state(cwd).map_err(|e| {
2767 uncertain_worktree_change(
2768 cwd,
2769 format!(
2770 "could not verify untracked files in {} after a read-only call: {}. The \
2771 worktree was kept for recovery.",
2772 cwd.display(),
2773 e.last_line()
2774 ),
2775 )
2776 })?;
2777 let leavings = baseline.ignored_untracked.read_only_leavings(&after);
2778 if leavings.condemning.is_empty() {
2779 return Ok(());
2780 }
2781 Err(uncertain_worktree_change(
2782 cwd,
2783 format!(
2784 "the read-only call left untracked file(s) in {} that the project does not \
2785 ignore: {}. The worktree was kept for recovery.",
2786 cwd.display(),
2787 list_paths(&leavings.condemning)
2788 ),
2789 ))
2790 }
2791
2792 fn allow_ignored_files(
2795 &self,
2796 cwd: &Path,
2797 baseline: &WorktreeBaseline,
2798 ) -> Result<AllowedArtifacts> {
2799 self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
2800 }
2801
2802 fn check_new_ignored_files(
2803 &self,
2804 cwd: &Path,
2805 baseline: &WorktreeBaseline,
2806 allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
2807 ) -> Result<AllowedArtifacts> {
2808 self.refuse_changed_attributes(cwd, baseline)?;
2809 let after = ignored_untracked_state(cwd).map_err(|e| {
2810 uncertain_worktree_change(
2811 cwd,
2812 format!(
2813 "could not verify untracked files in {} after editing: {}. The worktree was \
2814 kept for recovery.",
2815 cwd.display(),
2816 e.last_line()
2817 ),
2818 )
2819 })?;
2820 let changed = baseline.ignored_untracked.changed_paths(&after);
2821 if changed.is_empty() {
2822 return Ok(AllowedArtifacts::default());
2823 }
2824 let (ignored, changed): (Vec<_>, Vec<_>) = changed
2825 .into_iter()
2826 .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
2827 if changed.is_empty() {
2828 return Ok(AllowedArtifacts::split(ignored, &after));
2829 }
2830 let listed = list_paths(&changed);
2831 Err(uncertain_worktree_change(
2832 cwd,
2833 format!(
2834 "the editing call created or changed untracked or ignored file(s) in {} that \
2835 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2836 recovery.",
2837 cwd.display()
2838 ),
2839 ))
2840 }
2841
2842 pub(crate) fn refuse_changed_existing_untracked(
2847 &self,
2848 cwd: &Path,
2849 baseline: &WorktreeBaseline,
2850 ) -> Result<()> {
2851 self.check_changed_existing_untracked(cwd, baseline)
2852 .map(drop)
2853 }
2854
2855 fn allow_changed_ignored_artifacts(
2856 &self,
2857 cwd: &Path,
2858 baseline: &WorktreeBaseline,
2859 ) -> Result<AllowedArtifacts> {
2860 self.check_changed_existing_untracked(cwd, baseline)
2861 }
2862
2863 fn check_changed_existing_untracked(
2864 &self,
2865 cwd: &Path,
2866 baseline: &WorktreeBaseline,
2867 ) -> Result<AllowedArtifacts> {
2868 self.refuse_changed_attributes(cwd, baseline)?;
2869 let after = ignored_untracked_state(cwd).map_err(|e| {
2870 uncertain_worktree_change(
2871 cwd,
2872 format!(
2873 "could not verify existing untracked files in {} after editing: {}. The \
2874 worktree was kept for recovery.",
2875 cwd.display(),
2876 e.last_line()
2877 ),
2878 )
2879 })?;
2880 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2881 if changed.is_empty() {
2882 return Ok(AllowedArtifacts::default());
2883 }
2884 let (ignored, changed): (Vec<_>, Vec<_>) = changed
2885 .into_iter()
2886 .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
2887 if changed.is_empty() {
2888 return Ok(AllowedArtifacts::split(ignored, &after));
2889 }
2890 let listed = list_paths(&changed);
2891 Err(uncertain_worktree_change(
2892 cwd,
2893 format!(
2894 "the editing call changed or deleted existing untracked file(s) in {}: \
2895 {listed}. The worktree was kept for recovery.",
2896 cwd.display()
2897 ),
2898 ))
2899 }
2900
2901 pub(crate) fn refuse_unrepresented_tracked_changes(
2908 &self,
2909 cwd: &Path,
2910 baseline: &WorktreeBaseline,
2911 ) -> Result<()> {
2912 self.refuse_changed_attributes(cwd, baseline)?;
2913 let after = safe_git_state(cwd).map_err(|e| {
2914 uncertain_worktree_change(
2915 cwd,
2916 format!(
2917 "could not verify tracked files in {} after editing: {}. The worktree was \
2918 kept for recovery.",
2919 cwd.display(),
2920 e.last_line()
2921 ),
2922 )
2923 })?;
2924 let mut changed = Vec::new();
2925 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
2926 uncertain_worktree_change(
2927 cwd,
2928 format!(
2929 "could not record untracked files before verifying transformed content in {}: \
2930 {}. The worktree was kept for recovery.",
2931 cwd.display(),
2932 e.last_line()
2933 ),
2934 )
2935 })?;
2936 let mut filter_was_run = false;
2937 let mut filter_problem = None;
2938 let mut repositories: BTreeSet<PathBuf> =
2939 baseline.git_state.repositories.keys().cloned().collect();
2940 repositories.extend(after.repositories.keys().cloned());
2941 'repositories: for repository_path in repositories {
2942 let before_repository = baseline.git_state.repositories.get(&repository_path);
2943 let after_repository = after.repositories.get(&repository_path);
2944 if before_repository.is_none() || after_repository.is_none() {
2945 changed.push(repository_path.clone());
2946 continue;
2947 }
2948 if before_repository.map(|repository| &repository.gitlinks)
2949 != after_repository.map(|repository| &repository.gitlinks)
2950 {
2951 changed.push(repository_path.join("<gitlinks>"));
2952 }
2953 let mut paths = BTreeSet::new();
2954 if let Some(repository) = before_repository {
2955 paths.extend(repository.tracked.keys().cloned());
2956 }
2957 if let Some(repository) = after_repository {
2958 paths.extend(repository.tracked.keys().cloned());
2959 }
2960 for path in paths {
2961 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
2962 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
2963 let worktree_changed =
2964 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
2965 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
2966 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
2967 if !worktree_changed {
2968 continue;
2969 }
2970 if !index_changed {
2971 changed.push(repository_path.join(&path));
2972 continue;
2973 }
2974 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
2975 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
2976 let Some(current_entry) = current else {
2977 continue;
2978 };
2979 let Some(current_worktree) = current_worktree else {
2980 continue;
2981 };
2982 let content_changed =
2983 before_worktree.map(|file| file.content) != Some(current_worktree.content);
2984 let mode_changed = before_worktree.map(|file| file.mode.as_str())
2985 != Some(current_worktree.mode.as_str());
2986 let repository = cwd.join(&repository_path);
2987 let represented_content = if content_changed {
2988 filter_was_run = true;
2989 let result =
2990 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
2991 self.refuse_changed_attributes(cwd, baseline)?;
2992 match result {
2993 Ok(expected) => expected == current_worktree.content,
2994 Err(error) => {
2995 filter_problem = Some(format!(
2996 "could not verify transformed content for {:?}: {}",
2997 repository_path.join(&path),
2998 error.last_line()
2999 ));
3000 false
3001 }
3002 }
3003 } else {
3004 true
3005 };
3006 let represented_mode =
3007 !mode_changed || current_worktree.mode == current_entry.index_mode;
3008 if !represented_content || !represented_mode {
3009 changed.push(repository_path.join(&path));
3010 }
3011 if filter_problem.is_some() {
3012 break 'repositories;
3013 }
3014 }
3015 }
3016 if filter_was_run {
3017 self.refuse_changed_attributes(cwd, baseline)?;
3018 let verified = safe_git_state(cwd).map_err(|e| {
3019 uncertain_worktree_change(
3020 cwd,
3021 format!(
3022 "could not recheck tracked files after verifying transformed content in \
3023 {}: {}. The worktree was kept for recovery.",
3024 cwd.display(),
3025 e.last_line()
3026 ),
3027 )
3028 })?;
3029 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3030 uncertain_worktree_change(
3031 cwd,
3032 format!(
3033 "could not recheck untracked files after verifying transformed content \
3034 in {}: {}. The worktree was kept for recovery.",
3035 cwd.display(),
3036 e.last_line()
3037 ),
3038 )
3039 })?;
3040 if verified != after
3041 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3042 {
3043 return Err(uncertain_worktree_change(
3044 cwd,
3045 "a content filter changed the worktree while SPAR verified the managed \
3046 commit. The worktree was kept for recovery.",
3047 ));
3048 }
3049 self.refuse_changed_existing_untracked(cwd, baseline)?;
3050 }
3051 if let Some(problem) = filter_problem {
3052 return Err(uncertain_worktree_change(
3053 cwd,
3054 format!("{problem}. The worktree was kept for recovery."),
3055 ));
3056 }
3057 if changed.is_empty() {
3058 return Ok(());
3059 }
3060 let listed = list_paths(&changed);
3061 Err(uncertain_worktree_change(
3062 cwd,
3063 format!(
3064 "the editing call changed tracked working-file bytes, modes, repositories, or \
3065 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3066 recovery."
3067 ),
3068 ))
3069 }
3070
3071 pub(crate) fn refuse_changed_attributes(
3072 &self,
3073 cwd: &Path,
3074 baseline: &WorktreeBaseline,
3075 ) -> Result<()> {
3076 let after = attribute_state(cwd).map_err(|e| {
3077 uncertain_worktree_change(
3078 cwd,
3079 format!(
3080 "could not verify attribute files in {} after editing: {}. The worktree was \
3081 kept for recovery.",
3082 cwd.display(),
3083 e.last_line()
3084 ),
3085 )
3086 })?;
3087 if after == baseline.attributes {
3088 return Ok(());
3089 }
3090 Err(uncertain_worktree_change(
3091 cwd,
3092 format!(
3093 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3094 refused to run a Git operation that could select a new external filter.",
3095 cwd.display()
3096 ),
3097 ))
3098 }
3099
3100 pub(crate) fn commit_pending_changes(
3105 &self,
3106 cwd: &Path,
3107 baseline: &WorktreeBaseline,
3108 preferred_subject: &str,
3109 fallback_subject: &str,
3110 ) -> Result<bool> {
3111 let mut artifacts = IgnoredArtifacts::default();
3112 self.refuse_changed_attributes(cwd, baseline)?;
3113 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3114 refuse_unsafe_index_flags(cwd)?;
3115 if !self.has_uncommitted_changes(cwd)? {
3116 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3117 artifacts.report(cwd);
3118 return Ok(false);
3119 }
3120 self.stage_managed_changes(cwd, baseline).map_err(|e| {
3121 e.with_message(format!(
3122 "could not stage changes in {}: {}",
3123 cwd.display(),
3124 e.last_line()
3125 ))
3126 })?;
3127 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3130 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3131 if !changed_gitlinks.is_empty() {
3132 let listed = list_paths(&changed_gitlinks);
3133 bail!(
3134 "the editing call added or changed a gitlink at {listed}. It was staged but not \
3135 committed because the referenced repository objects might exist only inside \
3136 this worktree. The worktree was kept for recovery."
3137 );
3138 }
3139 let mut subject = self.clean_title(preferred_subject)?;
3140 if subject.trim().is_empty() {
3141 subject = self.clean_title(fallback_subject)?;
3142 }
3143 self.commit_staged_changes(cwd, &subject).map_err(|e| {
3144 e.with_message(format!(
3145 "could not commit changes in {}: {}. The staged files were kept.",
3146 cwd.display(),
3147 e.last_line()
3148 ))
3149 })?;
3150 if has_tracked_or_staged_work(cwd)? {
3151 bail!(
3152 "the commit in {} left additional uncommitted files. They were kept for \
3153 recovery.",
3154 cwd.display()
3155 );
3156 }
3157 artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3158 artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3159 artifacts.report(cwd);
3160 Ok(true)
3161 }
3162
3163 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3164 let after = ignored_untracked_state(cwd)?;
3165 self.git_at_without_automation(cwd, &["add", "-u"])?;
3166 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3167 if paths.is_empty() {
3168 return Ok(());
3169 }
3170 let mut input = Vec::new();
3171 for path in paths {
3172 input.extend(os_str_bytes(path.as_os_str())?);
3173 input.push(0);
3174 }
3175 let argv = git_without_automation_argv(&[
3176 "--literal-pathspecs",
3177 "add",
3178 "--pathspec-from-file=-",
3179 "--pathspec-file-nul",
3180 ]);
3181 proc::run_with_input_bytes(
3182 &argv,
3183 &self.git_opts(Some(cwd), true).stop_descendants(true),
3184 &input,
3185 )?;
3186 Ok(())
3187 }
3188
3189 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3192 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3193 .map(|_| ())
3194 }
3195
3196 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3203 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3204 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3205 .trim()
3206 .parse()
3207 .unwrap_or(0)
3208 }
3209
3210 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3214 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3215 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3216 .lines()
3217 .map(str::to_string)
3218 .collect()
3219 }
3220
3221 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3236 let ancestor = self
3237 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3238 .is_ok();
3239 if !ancestor {
3240 return None;
3241 }
3242 let range = format!("{earlier}..{later}");
3243 Some(
3244 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3245 .lines()
3246 .map(str::to_string)
3247 .collect(),
3248 )
3249 }
3250
3251 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3254 let range = format!("{}..{refname}", self.base_ref(cwd, base));
3255 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3256 .lines()
3257 .map(str::trim)
3258 .filter(|line| !line.is_empty())
3259 .map(str::to_string)
3260 .collect()
3261 }
3262
3263 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3279 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3280 self.git_try_at(
3281 Some(cwd),
3282 &["diff", "--name-only", "--no-renames", "-z", &range],
3283 )
3284 .split('\0')
3285 .filter(|path| !path.is_empty())
3286 .map(str::to_string)
3287 .collect()
3288 }
3289
3290 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3293 self.git_try_at(
3294 Some(cwd),
3295 &[
3296 "diff",
3297 "--name-only",
3298 "--no-renames",
3299 "-z",
3300 &format!("{from}..{to}"),
3301 ],
3302 )
3303 .split('\0')
3304 .filter(|path| !path.is_empty())
3305 .map(str::to_string)
3306 .collect()
3307 }
3308
3309 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3312 let base_ref = self.base_ref(cwd, base);
3313 let out = self
3314 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3315 .map_err(|e| {
3316 spar_err!(
3317 "could not find where {refname} and {base_ref} diverged. {}",
3318 e.last_line()
3319 )
3320 })?;
3321 let sha = out.trim().to_string();
3322 if sha.is_empty() {
3323 bail!("{refname} and {base_ref} share no history");
3324 }
3325 Ok(sha)
3326 }
3327
3328 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3329 let range = format!("{}...HEAD", self.base_ref(cwd, base));
3330 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3331 full.trim().to_string()
3332 }
3333
3334 pub fn rewrite_commits_if_needed(
3347 &self,
3348 cwd: &Path,
3349 base: &str,
3350 floor: Option<&str>,
3351 ) -> Result<()> {
3352 let base_ref = self.base_ref(cwd, base);
3353 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3354 self.report_commits_left_alone(cwd, &base_ref, floor);
3355
3356 let range = format!("{floor}..HEAD");
3357 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3358
3359 let offenders = raw
3360 .split('\x1e')
3361 .filter_map(|entry| entry.split_once('\0'))
3362 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3363 .count();
3364 if offenders == 0 {
3365 return Ok(());
3366 }
3367 logdim!("{offenders} commit message(s) violated style rules, rewriting");
3368
3369 let exe = self_binary()?;
3370 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3371
3372 let argv: Vec<String> = [
3373 "git",
3374 "filter-branch",
3375 "-f",
3376 "--msg-filter",
3377 &filter,
3378 &range,
3379 ]
3380 .iter()
3381 .map(|s| s.to_string())
3382 .collect();
3383 let opts = ExecOpts::new()
3384 .cwd(cwd)
3385 .check(false)
3386 .timeout_secs(600)
3387 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3388 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3389 .env(
3390 "SPAR_BAN_AI_ATTRIBUTION",
3391 bool_env(self.style.ban_ai_attribution),
3392 );
3393 let _ = proc::run(&argv, &opts);
3394
3395 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3396 if !style::violations(&after, &self.style).is_empty() {
3397 bail!(
3398 "commit messages still violate style rules after a rewrite in {}.",
3399 cwd.display()
3400 );
3401 }
3402 Ok(())
3403 }
3404
3405 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3410 if floor == base_ref {
3411 return;
3412 }
3413 let raw = self.git_try_at(
3414 Some(cwd),
3415 &[
3416 "log",
3417 &format!("{base_ref}..{floor}"),
3418 "--format=%H%x00%B%x1e",
3419 ],
3420 );
3421 let left: Vec<&str> = raw
3422 .split('\x1e')
3423 .filter_map(|entry| entry.split_once('\0'))
3424 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3425 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3426 .collect();
3427 if left.is_empty() {
3428 return;
3429 }
3430 logdim!(
3431 "{} commit message(s) already on the branch break the style rules and were left \
3432 alone: {}. spar does not rewrite commits it did not write.",
3433 left.len(),
3434 left.join(", ")
3435 );
3436 }
3437
3438 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3453 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3454 if let Some(head) = expected {
3455 self.refuse_push_that_drops(cwd, head)?;
3456 }
3457 let refspec = format!("HEAD:{branch}");
3458 let lease = match expected {
3459 Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
3460 None => "--force-with-lease".to_string(),
3461 };
3462 let pushed = self
3463 .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
3464 .map(|_| ())
3465 .map_err(|e| {
3466 spar_err!(
3467 "could not push to origin/{branch}. {}\nCheck push access and whether the \
3468 branch moved under you.",
3469 e.last_line()
3470 )
3471 });
3472 self.record_write(pushed)
3473 }
3474
3475 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
3483 let head = self.head_oid_checked(cwd)?;
3484 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
3485 return Ok(());
3486 }
3487 bail!(
3488 "refusing to push {head} because it does not contain the reviewed head {expected}. \
3489 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
3490 the intended commits on top of it before resuming.",
3491 cwd.display()
3492 )
3493 }
3494
3495 pub fn push_split_branch(
3504 &self,
3505 cwd: &Path,
3506 branch: &str,
3507 ) -> std::result::Result<(), SplitPushError> {
3508 let remote_ref = format!("refs/heads/{branch}");
3509 let lease = format!("--force-with-lease={remote_ref}:");
3510 let refspec = format!("HEAD:{remote_ref}");
3511 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
3512 Ok(_) => Ok(()),
3513 Err(push_error) => {
3514 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
3515 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
3516 reconcile_failed_split_push(branch, push_error, local, remote)
3517 }
3518 };
3519 self.record_write(result)
3520 }
3521
3522 pub fn gh(&self, args: &[&str]) -> Result<String> {
3525 self.gh_at(None, args)
3526 }
3527
3528 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
3529 let mut argv = vec!["gh".to_string()];
3530 argv.extend(args.iter().map(|s| s.to_string()));
3531 proc::run(
3532 &argv,
3533 &ExecOpts::new()
3534 .cwd(cwd.unwrap_or(&self.root))
3535 .timeout_secs(300),
3536 )
3537 }
3538
3539 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3545 let mut argv = vec!["gh".to_string()];
3546 argv.extend(args.iter().map(|s| s.to_string()));
3547 proc::run(
3548 &argv,
3549 &ExecOpts::new()
3550 .cwd(&self.root)
3551 .timeout_secs(300)
3552 .stdin(stdin),
3553 )
3554 }
3555
3556 pub fn gh_try(&self, args: &[&str]) -> String {
3557 let mut argv = vec!["gh".to_string()];
3558 argv.extend(args.iter().map(|s| s.to_string()));
3559 proc::run(
3560 &argv,
3561 &ExecOpts::new()
3562 .cwd(&self.root)
3563 .check(false)
3564 .timeout_secs(300),
3565 )
3566 .unwrap_or_default()
3567 }
3568
3569 pub fn viewer_login(&self) -> Result<&str> {
3580 if let Some(login) = self.viewer.get() {
3581 return Ok(login);
3582 }
3583 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3584 let login = if !rest.trim().is_empty() {
3585 rest.trim().to_string()
3586 } else {
3587 self.gh(&[
3590 "api",
3591 "graphql",
3592 "-f",
3593 "query={ viewer { login } }",
3594 "--jq",
3595 ".data.viewer.login",
3596 ])
3597 .map_err(|e| {
3598 spar_err!(
3599 "could not find out who `gh` is authenticated as, so spar cannot tell its \
3600 own comments from anybody else's. {}\nRun `gh auth status`.",
3601 e.last_line()
3602 )
3603 })?
3604 .trim()
3605 .to_string()
3606 };
3607 if login.is_empty() {
3608 bail!("`gh` reported an empty login. Run `gh auth status`.");
3609 }
3610 Ok(self.viewer.get_or_init(|| login))
3611 }
3612
3613 pub fn read_issue(&self, number: i64) -> Result<Issue> {
3619 let text = self
3620 .gh(&[
3621 "issue",
3622 "view",
3623 &number.to_string(),
3624 "--json",
3625 "number,title,body,labels,state,stateReason,url",
3626 ])
3627 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3628 serde_json::from_str(&text)
3629 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3630 }
3631
3632 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3633 let mut issues = Vec::new();
3634 for number in numbers {
3635 let text = self
3636 .gh(&[
3637 "issue",
3638 "view",
3639 &number.to_string(),
3640 "--json",
3641 "number,title,body,labels,state,stateReason,url",
3642 ])
3643 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3644 let issue: Issue = serde_json::from_str(&text)
3645 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3646 if issue.is_closed() {
3647 crate::log!("issue #{number} is closed, skipping");
3648 continue;
3649 }
3650 issues.push(issue);
3651 }
3652 if issues.is_empty() {
3653 bail!("no open issues to work on");
3654 }
3655 Ok(issues)
3656 }
3657
3658 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3664 #[derive(Deserialize)]
3665 struct Row {
3666 number: i64,
3667 }
3668 let text = self.gh(&[
3669 kind,
3670 "list",
3671 "--state",
3672 "open",
3673 "--limit",
3674 &FETCH_CEILING.to_string(),
3675 "--json",
3676 "number",
3677 ])?;
3678 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3679 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3680 numbers.sort_unstable();
3681
3682 let noun = if kind == "issue" { "issues" } else { "PRs" };
3683 let found = numbers.len();
3684 if min_number > 0 {
3685 numbers.retain(|n| *n >= min_number);
3686 let skipped = found - numbers.len();
3687 if skipped > 0 {
3688 crate::log!("{skipped} open {noun} below #{min_number} skipped");
3689 }
3690 }
3691 if found >= FETCH_CEILING {
3692 crate::log!(
3693 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3694 considered."
3695 );
3696 }
3697 if numbers.len() > limit {
3698 crate::log!(
3699 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3700 explicitly.",
3701 numbers.len()
3702 );
3703 numbers.truncate(limit);
3704 }
3705 Ok(numbers)
3706 }
3707
3708 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3710 self.open_numbers("issue", limit, min_number)
3711 }
3712
3713 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3714 self.open_numbers("pr", limit, min_number)
3715 }
3716
3717 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3718 self.branch_prs(branch, "open").into_iter().next()
3719 }
3720
3721 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3724 let text = self.gh(&[
3725 "pr",
3726 "list",
3727 "--head",
3728 branch,
3729 "--base",
3730 base,
3731 "--state",
3732 "open",
3733 "--json",
3734 "number,url,title,baseRefName",
3735 ])?;
3736 pr_for_base(&text, branch, base)
3737 }
3738
3739 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3743 self.branch_prs(branch, "all")
3744 }
3745
3746 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3747 let text = self.gh_try(&[
3748 "pr",
3749 "list",
3750 "--head",
3751 branch,
3752 "--state",
3753 state,
3754 "--json",
3755 "number,url,title",
3756 ]);
3757 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3758 }
3759
3760 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3766 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3767 let text = self
3768 .gh(&[
3769 "api",
3770 &path,
3771 "--jq",
3772 "if .pull_request then \"pr\" else \"issue\" end",
3773 ])
3774 .map_err(|e| {
3775 spar_err!(
3776 "no issue or pull request #{number} in this repository. {}",
3777 e.last_line()
3778 )
3779 })?;
3780 match text.trim() {
3781 "pr" => Ok(ItemKind::Pr),
3782 "issue" => Ok(ItemKind::Issue),
3783 other => Err(spar_err!(
3784 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3785 )),
3786 }
3787 }
3788
3789 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
3800 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
3801 if let Some(pr) = mine {
3802 return Ok(Some(pr));
3803 }
3804 let listed = match self.gh(&[
3805 "pr",
3806 "list",
3807 "--state",
3808 "open",
3809 "--limit",
3810 &FETCH_CEILING.to_string(),
3811 "--json",
3812 "number,url,title,baseRefName,closingIssuesReferences",
3813 ]) {
3814 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
3815 spar_err!("could not read the pull request list while looking for #{issue}")
3816 })?,
3817 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
3818 };
3819 if listed.len() >= FETCH_CEILING {
3820 logwarn!(
3821 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
3822 );
3823 }
3824 let default = self.default_branch(base);
3825 if !base.eq_ignore_ascii_case(&default) {
3826 logwarn!(
3830 "#{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."
3831 );
3832 }
3833 Ok(pick_linked_pr(&listed, issue))
3834 }
3835
3836 fn pr_for_branch_failing_closed(
3838 &self,
3839 branch: &str,
3840 base: &str,
3841 issue: i64,
3842 ) -> Result<Option<PrRef>> {
3843 match self.try_pr_for_branch(branch, base) {
3844 Ok(found) => Ok(found),
3845 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
3846 }
3847 }
3848
3849 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
3858 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
3859 return Err(spar_err!(
3860 "could not check whether a pull request already exists for #{issue}: {}. \
3861 Refusing to implement over the top of work that may already be open.",
3862 e.last_line()
3863 ));
3864 }
3865 logwarn!(
3866 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
3867 whether a pull request already exists for it.",
3868 e.last_line()
3869 );
3870 Ok(())
3871 }
3872
3873 pub fn pr_view(&self, number: i64) -> Result<PrView> {
3874 let text = self.gh(&[
3875 "pr",
3876 "view",
3877 &number.to_string(),
3878 "--json",
3879 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3880 ])?;
3881 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3882 }
3883
3884 fn try_pr_state(&self, number: i64) -> Result<String> {
3885 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3886 serde_json::from_str::<Value>(text.trim())
3887 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3888 .get("state")
3889 .and_then(Value::as_str)
3890 .map(str::to_string)
3891 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3892 }
3893
3894 pub fn pr_state(&self, number: i64) -> String {
3895 self.try_pr_state(number).unwrap_or_default()
3896 }
3897
3898 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3900 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3901 let oid = serde_json::from_str::<Value>(&text)
3902 .ok()
3903 .and_then(|value| {
3904 value
3905 .get("headRefOid")
3906 .and_then(Value::as_str)
3907 .map(str::trim)
3908 .filter(|oid| !oid.is_empty())
3909 .map(str::to_string)
3910 })
3911 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3912 Ok(oid)
3913 }
3914
3915 pub fn create_pr(
3916 &self,
3917 cwd: &Path,
3918 branch: &str,
3919 base: &str,
3920 title: &str,
3921 body: &str,
3922 ) -> Result<PrRef> {
3923 let title = self.record_failed_write(self.clean_title(title))?;
3924 let body = self.record_failed_write(self.clean(body))?;
3925 let mut argv = vec![
3926 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3927 ];
3928 if self.drafts != Drafts::Never {
3929 argv.push("--draft");
3930 }
3931 let created = self.gh_at(Some(cwd), &argv);
3932 let found = self.try_pr_for_branch(branch, base);
3933 self.record_write(reconcile_pr_creation(branch, created, found))
3934 }
3935
3936 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3937 let body = signed(&self.record_failed_write(self.clean(body))?);
3938 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3939 if has_exact_comment(&comments, &body) {
3940 return Ok(());
3941 }
3942 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3943 let result = match posted {
3944 Ok(_) => Ok(()),
3945 Err(post_error) => {
3946 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3947 }
3948 };
3949 self.record_write(result)
3950 }
3951
3952 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3953 let body = signed(&self.record_failed_write(self.clean(body))?);
3954 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3955 if has_exact_comment(&comments, &body) {
3956 return Ok(());
3957 }
3958 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3959 let result = match posted {
3960 Ok(_) => Ok(()),
3961 Err(post_error) => {
3962 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3963 }
3964 };
3965 self.record_write(result)
3966 }
3967
3968 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3973 self.comment_issue(number, body)?;
3974 let n = number.to_string();
3975 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3976 Ok(_) => Ok(()),
3977 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3979 spar_err!(
3980 "commented on #{number} but could not close it: {}",
3981 e.last_line()
3982 )
3983 }),
3984 };
3985 self.record_write(closed)
3986 }
3987
3988 pub fn edit_issue_body(
4003 &self,
4004 number: i64,
4005 expected: &str,
4006 body: &str,
4007 inserted: &str,
4008 ) -> Result<()> {
4009 let cleaned = self.record_failed_write(self.clean(inserted))?;
4010 if cleaned.trim() != inserted.trim() {
4011 return self.record_failed_write(Err(spar_err!(
4012 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
4013 )));
4014 }
4015 let current = self.record_failed_write(self.issue_body(number))?;
4016 if current != expected {
4017 return self.record_failed_write(Err(spar_err!(
4018 "the body of #{number} changed since it was read, so it was left alone rather \
4019 than written over."
4020 )));
4021 }
4022 let edited = self.gh_stdin(
4023 &["issue", "edit", &number.to_string(), "--body-file", "-"],
4024 body,
4025 );
4026 let result = match edited {
4027 Ok(_) => Ok(()),
4028 Err(edit_error) => {
4029 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
4030 }
4031 };
4032 self.record_write(result)
4033 }
4034
4035 pub fn issue_body(&self, number: i64) -> Result<String> {
4037 #[derive(Deserialize)]
4038 struct Row {
4039 #[serde(default)]
4040 body: Option<String>,
4041 }
4042 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
4043 let row: Row = serde_json::from_str(text.trim())
4044 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4045 Ok(row.body.unwrap_or_default())
4046 }
4047
4048 pub fn open_issue_rows(&self) -> Vec<Issue> {
4053 let text = self.gh_try(&[
4054 "issue",
4055 "list",
4056 "--state",
4057 "open",
4058 "--limit",
4059 &FETCH_CEILING.to_string(),
4060 "--json",
4061 "number,title,body,labels,state,stateReason,url",
4062 ]);
4063 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
4064 }
4065
4066 pub fn open_pr_rows(&self) -> Vec<PrRow> {
4068 let text = self.gh_try(&[
4069 "pr",
4070 "list",
4071 "--state",
4072 "open",
4073 "--limit",
4074 &FETCH_CEILING.to_string(),
4075 "--json",
4076 "number,title,changedFiles,additions,deletions",
4077 ]);
4078 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
4079 }
4080
4081 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
4082 self.create_issue_apart_from(title, body, None)
4083 }
4084
4085 pub fn create_issue_apart_from(
4086 &self,
4087 title: &str,
4088 body: &str,
4089 apart_from: Option<i64>,
4090 ) -> Result<String> {
4091 let title = self.record_failed_write(self.clean_title(title))?;
4092 let body = self.record_failed_write(self.clean_issue_body(body))?;
4093 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
4094 let result = match created {
4095 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
4096 created => {
4097 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
4098 reconcile_issue_creation(&title, created, found)
4099 }
4100 };
4101 self.record_write(result)
4102 }
4103}
4104
4105#[derive(Debug, Clone)]
4107pub struct ExistingIssue {
4108 pub number: i64,
4109 pub url: String,
4110 pub title: String,
4111 pub body: String,
4112 pub open: bool,
4113}
4114
4115impl Repo {
4116 pub(crate) fn try_exact_issue_apart_from(
4117 &self,
4118 title: &str,
4119 body: &str,
4120 apart_from: Option<i64>,
4121 ) -> Result<Option<ExistingIssue>> {
4122 #[derive(Deserialize)]
4123 #[serde(rename_all = "camelCase")]
4124 struct Row {
4125 number: i64,
4126 #[serde(default)]
4127 title: String,
4128 #[serde(default)]
4129 url: String,
4130 #[serde(default)]
4131 body: Option<String>,
4132 #[serde(default)]
4133 state: String,
4134 }
4135
4136 let text = self.gh(&[
4137 "issue",
4138 "list",
4139 "--state",
4140 "all",
4141 "--limit",
4142 "100",
4143 "--json",
4144 "number,title,url,body,state,stateReason",
4145 ])?;
4146 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4147 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
4148 Ok(rows
4149 .into_iter()
4150 .filter(|row| Some(row.number) != apart_from)
4151 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
4152 .map(|row| ExistingIssue {
4153 number: row.number,
4154 url: row.url,
4155 title: row.title,
4156 body: row.body.unwrap_or_default(),
4157 open: row.state.eq_ignore_ascii_case("open"),
4158 }))
4159 }
4160
4161 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
4169 self.find_similar_issue_apart_from(title, body, None)
4170 }
4171
4172 pub fn find_similar_issue_apart_from(
4180 &self,
4181 title: &str,
4182 body: &str,
4183 apart_from: Option<i64>,
4184 ) -> Option<ExistingIssue> {
4185 self.try_similar_issue(title, body, apart_from, true)
4186 .ok()
4187 .flatten()
4188 }
4189
4190 pub fn try_find_similar_issue_apart_from(
4192 &self,
4193 title: &str,
4194 body: &str,
4195 apart_from: Option<i64>,
4196 ) -> Result<Option<ExistingIssue>> {
4197 self.try_similar_issue(title, body, apart_from, false)
4198 }
4199
4200 fn try_similar_issue(
4202 &self,
4203 title: &str,
4204 body: &str,
4205 apart_from: Option<i64>,
4206 include_closed: bool,
4207 ) -> Result<Option<ExistingIssue>> {
4208 #[derive(Deserialize)]
4209 #[serde(rename_all = "camelCase")]
4210 struct Row {
4211 number: i64,
4212 #[serde(default)]
4213 title: String,
4214 #[serde(default)]
4215 url: String,
4216 #[serde(default)]
4217 body: String,
4218 #[serde(default)]
4219 state: String,
4220 }
4221 if title.trim().is_empty() {
4222 return Ok(None);
4223 }
4224 let query: String = title
4227 .chars()
4228 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4229 .take(120)
4230 .collect();
4231 let state = if include_closed { "all" } else { "open" };
4240 let text = self.gh(&[
4241 "issue",
4242 "list",
4243 "--state",
4244 state,
4245 "--limit",
4246 "100",
4247 "--search",
4248 query.trim(),
4249 "--json",
4250 "number,title,url,body,state,stateReason",
4251 ])?;
4252 let rows: Vec<Row> = serde_json::from_str(text.trim())
4253 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
4254
4255 Ok(rows
4256 .into_iter()
4257 .filter(|row| Some(row.number) != apart_from)
4258 .find(|row| {
4259 row.title.trim().eq_ignore_ascii_case(title.trim())
4260 || textsim::covers(title, body, &row.title, &row.body)
4261 })
4262 .map(|row| ExistingIssue {
4263 number: row.number,
4264 url: row.url,
4265 title: row.title,
4266 open: row.state.eq_ignore_ascii_case("open"),
4267 body: row.body,
4268 }))
4269 }
4270
4271 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
4273 #[derive(Deserialize)]
4274 struct Row {
4275 title: String,
4276 url: String,
4277 }
4278 let needle = title.trim().to_lowercase();
4279 if needle.is_empty() {
4280 return None;
4281 }
4282 let query: String = title
4284 .chars()
4285 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4286 .take(120)
4287 .collect();
4288 let text = self.gh_try(&[
4289 "issue",
4290 "list",
4291 "--state",
4292 "all",
4293 "--limit",
4294 "100",
4295 "--search",
4296 query.trim(),
4297 "--json",
4298 "number,title,url",
4299 ]);
4300 serde_json::from_str::<Vec<Row>>(text.trim())
4301 .ok()?
4302 .into_iter()
4303 .find(|row| row.title.trim().to_lowercase() == needle)
4304 .map(|row| row.url)
4305 }
4306
4307 pub fn mark_ready(&self, number: i64) -> bool {
4315 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
4316 Ok(_) => true,
4317 Err(e) => {
4318 logdim!(
4319 "PR #{number} is approved but could not be taken out of draft: {}",
4320 e.last_line()
4321 );
4322 false
4323 }
4324 }
4325 }
4326
4327 fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
4329 let listed = self
4330 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
4331 .ok()?;
4332 let wanted = format!("branch refs/heads/{branch}");
4333 let mut current: Option<PathBuf> = None;
4334 for line in listed.lines() {
4335 if let Some(path) = line.strip_prefix("worktree ") {
4336 current = Some(PathBuf::from(path));
4337 } else if line == wanted {
4338 return current;
4339 }
4340 }
4341 None
4342 }
4343
4344 fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
4360 let branch = self.pr_view(number).ok()?.head_ref_name;
4361 if branch.is_empty() || !self.known_branches().contains_key(&branch) {
4362 return None;
4363 }
4364 let mut released = None;
4365 if let Some(path) = self.worktree_holding(&branch) {
4366 if !path.starts_with(self.root.join(WORKTREE_DIR)) {
4367 return None;
4368 }
4369 if !matches!(
4370 self.has_recoverable_work(&path, Disposable::BuildOutput),
4371 Ok(false)
4372 ) {
4373 return None;
4374 }
4375 match self.remove_worktree_at(&path) {
4376 Ok(true) => released = Some(path),
4377 _ => return None,
4378 }
4379 }
4380 match self.delete_branch_if_safe(&branch) {
4381 Ok(true) => {
4382 self.forget_branch(&branch);
4383 Some(match released {
4384 Some(path) => format!(
4385 " The worktree at {} was released and branch {branch} deleted.",
4386 path.display()
4387 ),
4388 None => format!(" Branch {branch} was deleted."),
4389 })
4390 }
4391 _ => released.map(|path| {
4392 format!(
4393 " The worktree at {} was released, but branch {branch} was kept.",
4394 path.display()
4395 )
4396 }),
4397 }
4398 }
4399
4400 pub fn merge_pr(&self, number: i64) -> Result<()> {
4404 let n = number.to_string();
4405 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4406 Ok(_) => Ok(()),
4407 Err(e) => {
4408 if self.pr_state(number) == "MERGED" {
4409 let finished = self
4410 .finish_merged_branch_cleanup(number)
4411 .unwrap_or_default();
4412 logdim!(
4413 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4414 e.last_line()
4415 );
4416 Ok(())
4417 } else {
4418 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4419 }
4420 }
4421 };
4422 self.record_write(merged)
4423 }
4424
4425 pub fn merge_pr_at_head(
4427 &self,
4428 number: i64,
4429 expected_head: &str,
4430 delete_branch: bool,
4431 ) -> Result<()> {
4432 let n = number.to_string();
4433 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4434 Ok(_) => Ok(()),
4435 Err(e) => {
4436 if self.pr_state(number) == "MERGED" {
4437 let finished = self
4438 .finish_merged_branch_cleanup(number)
4439 .unwrap_or_default();
4440 logdim!(
4441 "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4442 e.last_line()
4443 );
4444 Ok(())
4445 } else {
4446 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4447 }
4448 }
4449 };
4450 self.record_write(merged)
4451 }
4452
4453 pub fn followups_path(&self) -> PathBuf {
4458 self.root.join(STATE_DIR).join("followups.md")
4459 }
4460
4461 pub fn brainstorms_dir(&self) -> PathBuf {
4466 self.root.join(STATE_DIR).join("brainstorms")
4467 }
4468
4469 pub fn worked_followups_path(&self) -> PathBuf {
4477 self.root.join(STATE_DIR).join("followups.done.md")
4478 }
4479
4480 pub fn spend_path(&self) -> PathBuf {
4483 self.root.join(STATE_DIR).join("state").join("spend.json")
4484 }
4485
4486 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4492 if calls.is_empty() {
4493 return Ok(());
4494 }
4495 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4496 .ok()
4497 .and_then(|text| serde_json::from_str(&text).ok())
4498 .unwrap_or_default();
4499 all.extend(calls.iter().cloned());
4500 if let Some(parent) = self.spend_path().parent() {
4501 let _ = std::fs::create_dir_all(parent);
4502 }
4503 write_json_atomic(&self.spend_path(), &all)
4504 }
4505
4506 pub fn implementors_path(&self) -> PathBuf {
4512 self.root
4513 .join(STATE_DIR)
4514 .join("state")
4515 .join("implementors.json")
4516 }
4517
4518 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4519 std::fs::read_to_string(self.implementors_path())
4520 .ok()
4521 .and_then(|text| serde_json::from_str(&text).ok())
4522 .unwrap_or_default()
4523 }
4524
4525 pub fn remember_implementor(&self, issue: i64, agent: &str) {
4528 let mut known = self.read_implementors();
4529 known.insert(issue, agent.to_string());
4530 if let Some(parent) = self.implementors_path().parent() {
4531 let _ = std::fs::create_dir_all(parent);
4532 }
4533 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4534 logdim!("could not record who implemented #{issue}: {e}");
4535 }
4536 }
4537
4538 pub fn contested_path(&self) -> PathBuf {
4543 self.root
4544 .join(STATE_DIR)
4545 .join("state")
4546 .join("contested.json")
4547 }
4548
4549 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4551 std::fs::read_to_string(self.contested_path())
4552 .ok()
4553 .and_then(|text| serde_json::from_str(&text).ok())
4554 .unwrap_or_default()
4555 }
4556
4557 pub fn remember_contested(&self, items: &[ContestedItem]) {
4562 if items.is_empty() {
4563 return;
4564 }
4565 let mut known = self.read_contested();
4566 for item in items {
4567 known.insert(
4568 item.issue,
4569 Contested {
4570 title: item.title.clone(),
4571 positions: item.positions.clone(),
4572 reasons: item.reasons.clone(),
4573 },
4574 );
4575 }
4576 if let Some(parent) = self.contested_path().parent() {
4577 let _ = std::fs::create_dir_all(parent);
4578 }
4579 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4580 logdim!("could not record the contested issues: {e}");
4581 }
4582 }
4583
4584 pub fn forget_contested(&self, numbers: &[i64]) {
4586 let mut known = self.read_contested();
4587 let before = known.len();
4588 known.retain(|number, _| !numbers.contains(number));
4589 if known.len() == before {
4590 return;
4591 }
4592 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4593 logdim!("could not update the contested issues: {e}");
4594 }
4595 }
4596
4597 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4598 self.root
4599 .join(STATE_DIR)
4600 .join("state")
4601 .join(format!("checkin-{number}.json"))
4602 }
4603
4604 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4619 let path = self.followups_path();
4620 let heading = format!("## {}", title.trim());
4621 for seen in [&path, &self.worked_followups_path()] {
4622 if let Ok(existing) = std::fs::read_to_string(seen) {
4623 if has_heading(&existing, title) {
4624 logdim!("follow-up already noted: {title}");
4625 return Followup::Covered(format!("note: {}", title.trim()));
4626 }
4627 }
4628 }
4629 if let Some(parent) = path.parent() {
4630 let _ = std::fs::create_dir_all(parent);
4631 }
4632 use std::io::Write;
4633 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4640 match std::fs::OpenOptions::new()
4641 .create(true)
4642 .append(true)
4643 .open(&path)
4644 {
4645 Ok(mut file) => match file.write_all(entry.as_bytes()) {
4646 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4647 Err(e) => {
4648 logdim!("could not write {}: {e}", path.display());
4649 Followup::Failed
4650 }
4651 },
4652 Err(e) => {
4653 logdim!("could not write {}: {e}", path.display());
4654 Followup::Failed
4655 }
4656 }
4657 }
4658
4659 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4664 let path = self.worked_followups_path();
4665 if let Some(parent) = path.parent() {
4666 let _ = std::fs::create_dir_all(parent);
4667 }
4668 use std::io::Write;
4669 let entry = format!(
4670 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4671 title.trim(),
4672 body.trim()
4673 );
4674 if let Ok(mut file) = std::fs::OpenOptions::new()
4675 .create(true)
4676 .append(true)
4677 .open(&path)
4678 {
4679 let _ = file.write_all(entry.as_bytes());
4680 }
4681 }
4682
4683 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4692 self.root
4693 .join(STATE_DIR)
4694 .join("reviews")
4695 .join(format!("pr-{number}.md"))
4696 }
4697
4698 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4704 let path = self.pending_comment_path(number);
4705 if let Some(parent) = path.parent() {
4706 std::fs::create_dir_all(parent)
4707 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4708 }
4709 std::fs::write(&path, text)
4710 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4711 Ok(path)
4712 }
4713
4714 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4715 std::fs::read_to_string(self.pending_comment_path(number)).ok()
4716 }
4717
4718 pub fn state_path(&self, number: i64) -> PathBuf {
4719 self.root
4720 .join(STATE_DIR)
4721 .join("state")
4722 .join(format!("pr-{number}.json"))
4723 }
4724
4725 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4726 let path = self.state_path(number);
4727 let text = std::fs::read_to_string(&path).ok()?;
4728 match serde_json::from_str(&text) {
4729 Ok(state) => Some(state),
4730 Err(_) => {
4731 logdim!("could not read {}, starting fresh", path.display());
4732 None
4733 }
4734 }
4735 }
4736
4737 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4738 if let Some(local) = self.read_local_state(pr.number) {
4739 return Some(local);
4740 }
4741 if self.state_store.writes_pr() {
4742 return self.read_pr_state(pr.number);
4743 }
4744 None
4745 }
4746
4747 pub(crate) fn read_state_for_head(
4748 &self,
4749 pr: &PrView,
4750 actual_head: &str,
4751 ) -> Option<PersistedState> {
4752 let local = self
4753 .state_store
4754 .writes_local()
4755 .then(|| self.read_local_state(pr.number))
4756 .flatten();
4757 let remote = self
4758 .state_store
4759 .writes_pr()
4760 .then(|| self.read_pr_state(pr.number))
4761 .flatten();
4762 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4763 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4764 self.remember_checkpoint(pr.number, checkpoint);
4765 }
4766 choose_state_for_head(candidates, actual_head)
4767 }
4768
4769 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4770 self.try_read_pr_state(number).ok().flatten()
4771 }
4772
4773 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4774 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4775 if let Some(state) = parse_state_comment(&body) {
4776 return Ok(Some(state));
4777 }
4778 }
4779 Ok(None)
4780 }
4781
4782 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4783 let remote_state = if self.state_store.writes_pr() {
4784 self.try_read_pr_state(number)
4785 } else {
4786 Ok(None)
4787 };
4788 self.write_state_after_remote_read(number, state, remote_state)
4789 }
4790
4791 fn write_state_after_remote_read(
4792 &self,
4793 number: i64,
4794 state: &PersistedState,
4795 remote_state: Result<Option<PersistedState>>,
4796 ) -> Result<()> {
4797 let remote_checkpoint = if self.state_store.writes_pr() {
4798 self.record_failed_write(remote_state)?
4799 .map(|saved| saved.checkpoint)
4800 .unwrap_or_default()
4801 } else {
4802 0
4803 };
4804 let local_checkpoint = self
4805 .state_store
4806 .writes_local()
4807 .then(|| self.read_local_state(number))
4808 .flatten()
4809 .map(|saved| saved.checkpoint)
4810 .unwrap_or_default();
4811 let mut stamped = state.clone();
4812 stamped.checkpoint = state
4813 .checkpoint
4814 .max(local_checkpoint)
4815 .max(remote_checkpoint)
4816 .max(self.remembered_checkpoint(number))
4817 .saturating_add(1);
4818 self.remember_checkpoint(number, stamped.checkpoint);
4819 if self.state_store.writes_local() {
4820 write_json_atomic(&self.state_path(number), &stamped)?;
4821 }
4822 if self.state_store.writes_pr() {
4823 self.write_pr_state(number, &stamped)?;
4824 }
4825 Ok(())
4826 }
4827
4828 fn remembered_checkpoint(&self, number: i64) -> u64 {
4829 self.checkpoints
4830 .lock()
4831 .unwrap_or_else(std::sync::PoisonError::into_inner)
4832 .get(&number)
4833 .copied()
4834 .unwrap_or_default()
4835 }
4836
4837 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4838 let mut checkpoints = self
4839 .checkpoints
4840 .lock()
4841 .unwrap_or_else(std::sync::PoisonError::into_inner);
4842 let saved = checkpoints.entry(number).or_default();
4843 *saved = (*saved).max(checkpoint);
4844 }
4845
4846 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4847 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4851 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4852 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4853 if let Some(id) = comment_id {
4854 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4855 let field = format!("body={body}");
4856 let written = self
4857 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4858 .map(|_| ());
4859 return self.record_write(written);
4860 }
4861 let written = self
4862 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4863 .map(|_| ());
4864 self.record_write(written)
4865 }
4866
4867 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4875 self.try_issue_comments(number).unwrap_or_default()
4876 }
4877
4878 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4879 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4880 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4881 }
4882
4883 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4890 let viewer = self.viewer_login()?.to_string();
4891 let mut mine = Vec::new();
4892 let mut theirs = Vec::new();
4893 for comment in self.try_issue_comments(number)? {
4894 let Some(body) = comment.get("body").and_then(Value::as_str) else {
4895 continue;
4896 };
4897 if !body.contains("spar:state") {
4898 continue;
4899 }
4900 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4901 continue;
4902 };
4903 let author = comment
4904 .get("user")
4905 .and_then(|u| u.get("login"))
4906 .and_then(Value::as_str)
4907 .unwrap_or_default();
4908 if is_state_comment(body, author, &viewer) {
4909 mine.push((id, body.to_string()));
4910 } else {
4911 theirs.push(format!("@{author}"));
4912 }
4913 }
4914 if !theirs.is_empty() {
4915 logdim!(
4916 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4917 own state block is patched or deleted.",
4918 theirs.len(),
4919 theirs.join(", ")
4920 );
4921 }
4922 Ok(mine)
4923 }
4924
4925 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4926 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4927 }
4928
4929 pub fn clear_state(&self, number: i64) {
4931 let path = self.state_path(number);
4932 let _ = std::fs::remove_file(&path);
4933 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4934 }
4935
4936 pub fn prune_state(&self) -> Vec<String> {
4940 let base = self.root.join(STATE_DIR).join("state");
4941 let Ok(entries) = std::fs::read_dir(&base) else {
4942 return Vec::new();
4943 };
4944 let mut names: Vec<String> = entries
4945 .flatten()
4946 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4947 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4948 .collect();
4949 names.sort();
4950
4951 let mut removed = Vec::new();
4952 for name in names {
4953 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4954 continue;
4955 };
4956 if is_finished(&self.pr_state(number)) {
4957 let _ = std::fs::remove_file(base.join(&name));
4958 removed.push(format!("state {name}"));
4959 }
4960 }
4961 removed
4962 }
4963
4964 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4968 #[derive(Deserialize)]
4969 struct Row {
4970 number: i64,
4971 }
4972 let numbers = match numbers {
4973 Some(numbers) => numbers,
4974 None => {
4975 let listed: Result<Vec<i64>> = (|| {
4976 let text = self.gh(&[
4977 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4978 ])?;
4979 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4980 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4981 Ok(rows.into_iter().map(|row| row.number).collect())
4982 })();
4983 match self.record_failed_write(listed) {
4984 Ok(numbers) => numbers,
4985 Err(e) => {
4986 logdim!("could not inspect pull requests for state cleanup: {e}");
4987 return Vec::new();
4988 }
4989 }
4990 }
4991 };
4992
4993 let mut removed = Vec::new();
4994 for number in numbers {
4995 let state = match self.record_failed_write(self.try_pr_state(number)) {
4996 Ok(state) => state,
4997 Err(e) => {
4998 logdim!("could not inspect PR #{number} for state cleanup: {e}");
4999 continue;
5000 }
5001 };
5002 if !is_finished(&state) {
5003 continue;
5004 }
5005 let comments = match self.record_failed_write(self.try_state_comments(number)) {
5006 Ok(comments) => comments,
5007 Err(e) => {
5008 logdim!("could not inspect state comments on PR #{number}: {e}");
5009 continue;
5010 }
5011 };
5012 for (id, _) in comments {
5013 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5014 let deleted = self
5015 .gh(&["api", "-X", "DELETE", &path, "--silent"])
5016 .map(|_| ());
5017 match self.record_write(deleted) {
5018 Ok(()) => removed.push(format!("state comment on PR #{number}")),
5019 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
5020 }
5021 }
5022 }
5023 removed
5024 }
5025
5026 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
5033 let base = self.root.join(WORKTREE_DIR);
5034 let mut removed = Vec::new();
5035 let known = self.known_branches();
5036
5037 if let Ok(entries) = std::fs::read_dir(&base) {
5038 let mut names: Vec<String> = entries
5039 .flatten()
5040 .filter(|e| e.path().is_dir())
5041 .filter_map(|e| e.file_name().to_str().map(str::to_string))
5042 .collect();
5043 names.sort();
5044
5045 for name in names {
5046 if let Some(rest) = name.strip_prefix("review-") {
5049 let number: i64 = rest.parse().unwrap_or(-1);
5050 if !(force_all || is_finished(&self.pr_state(number))) {
5051 continue;
5052 }
5053 let path = base.join(&name);
5054 if force_all {
5055 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
5056 if !belongs {
5057 return Ok(false);
5058 }
5059 let local_ref = review_ref(number);
5060 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
5061 return Ok(false);
5062 }
5063 let head = self.head_oid_checked(&path)?;
5064 let recorded = self
5065 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
5066 .trim()
5067 .to_string();
5068 Ok(head == recorded)
5069 });
5070 match owned {
5071 Ok(true) => {}
5072 Ok(false) => {
5073 logdim!(
5074 "kept {} because no matching SPAR review reference proves \
5075 ownership",
5076 path.display()
5077 );
5078 continue;
5079 }
5080 Err(e) => {
5081 logdim!(
5082 "kept {} because review ownership could not be verified: {}",
5083 path.display(),
5084 e.last_line()
5085 );
5086 continue;
5087 }
5088 }
5089 } else {
5090 if let Err(e) = self.refuse_review_worktree_changes(number) {
5091 logdim!(
5092 "kept {} because its review state could not be verified as \
5093 disposable: {}",
5094 path.display(),
5095 e.last_line()
5096 );
5097 continue;
5098 }
5099 }
5100 if force_all {
5101 if self.remove_worktree_at_force(&path) {
5102 self.git_try(&["update-ref", "-d", &review_ref(number)]);
5103 }
5104 } else {
5105 self.release_review_worktree(number);
5106 }
5107 if !path.exists() {
5108 removed.push(name);
5109 }
5110 continue;
5111 }
5112 let branch = format!("{}{name}", self.branch_prefix);
5113 if !(force_all || self.worktree_is_done(&branch)) {
5114 continue;
5115 }
5116 if !known.contains_key(&branch) {
5117 logdim!("kept {branch} because it has no branch record");
5118 continue;
5119 }
5120 let path = base.join(&name);
5121 if !force_all {
5122 match self.has_recoverable_work(&path, Disposable::BuildOutput) {
5123 Ok(true) => {
5124 logdim!(
5125 "kept {} because it contains uncommitted changes or ignored files",
5126 path.display()
5127 );
5128 continue;
5129 }
5130 Err(e) => {
5131 logdim!(
5132 "kept {} because its Git state could not be checked: {}",
5133 path.display(),
5134 e.last_line()
5135 );
5136 continue;
5137 }
5138 Ok(false) => {}
5139 }
5140 match self.branch_deletion_is_safe(&branch) {
5141 Ok(true) => {}
5142 Ok(false) => {
5143 logdim!(
5144 "kept {branch} because no surviving ref preserves its tip or \
5145 reflog-only commits"
5146 );
5147 continue;
5148 }
5149 Err(e) => {
5150 logdim!(
5151 "kept {branch} because preservation could not be verified: {}",
5152 e.last_line()
5153 );
5154 continue;
5155 }
5156 }
5157 }
5158 let removed_worktree = if force_all {
5159 self.remove_worktree_at_force(&path)
5160 } else {
5161 match self.remove_worktree_at(&path) {
5162 Ok(removed) => removed,
5163 Err(error) => {
5164 logdim!(
5165 "kept {branch} and {} because removal did not reach a confirmed \
5166 quiet point: {}",
5167 path.display(),
5168 error.last_line()
5169 );
5170 false
5171 }
5172 }
5173 };
5174 if !removed_worktree {
5175 continue;
5176 }
5177 if force_all {
5178 self.git_try(&["branch", "-D", &branch]);
5179 self.forget_branch(&branch);
5180 } else {
5181 match self.delete_branch_if_safe(&branch) {
5182 Ok(true) => self.forget_branch(&branch),
5183 Ok(false) => logdim!(
5184 "kept {branch} because its tip or reflog changed before deletion"
5185 ),
5186 Err(error) => logdim!(
5187 "kept {branch} because deletion safety could not be rechecked: {}",
5188 error.last_line()
5189 ),
5190 }
5191 }
5192 removed.push(name);
5193 }
5194 }
5195 removed.extend(self.prune_branches(force_all));
5196 removed
5197 }
5198
5199 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
5206 let known = self.known_branches();
5207 let branches: Vec<String> = known.keys().cloned().collect();
5208 if branches.is_empty() {
5209 return Vec::new();
5210 }
5211
5212 let checked_out: Vec<String> = self
5213 .git_try(&["worktree", "list", "--porcelain"])
5214 .lines()
5215 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
5216 .collect();
5217
5218 let existing: Vec<String> = self
5221 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
5222 .lines()
5223 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
5224 .collect();
5225
5226 let mut removed = Vec::new();
5227 for branch in branches {
5228 if !existing.contains(&branch) {
5229 self.forget_branch(&branch); continue;
5231 }
5232 if checked_out.contains(&branch) {
5233 continue;
5234 }
5235 if !(force_all || self.worktree_is_done(&branch)) {
5236 continue;
5237 }
5238 if !force_all {
5239 let Some(_record) = known.get(&branch) else {
5240 continue;
5241 };
5242 match self.branch_deletion_is_safe(&branch) {
5243 Ok(true) => {}
5244 Ok(false) => {
5245 logdim!(
5246 "kept {branch} because no surviving ref preserves its tip or \
5247 reflog-only commits"
5248 );
5249 continue;
5250 }
5251 Err(e) => {
5252 logdim!(
5253 "kept {branch} because preservation could not be verified: {}",
5254 e.last_line()
5255 );
5256 continue;
5257 }
5258 }
5259 }
5260 let deleted = if force_all {
5261 self.git(&["branch", "-D", &branch]).map(|_| true)
5262 } else {
5263 self.delete_branch_if_safe(&branch)
5264 };
5265 match deleted {
5266 Ok(true) => {
5267 self.forget_branch(&branch);
5268 removed.push(format!("branch {branch}"));
5269 }
5270 Ok(false) => {
5271 logdim!("kept {branch} because its tip or reflog changed before deletion");
5272 }
5273 Err(e) => {
5274 logdim!("could not delete {branch}: {}", e.last_line());
5277 }
5278 }
5279 }
5280 removed
5281 }
5282
5283 fn worktree_is_done(&self, branch: &str) -> bool {
5285 #[derive(Deserialize)]
5286 struct Row {
5287 state: String,
5288 }
5289 let entry = branch
5290 .strip_prefix(self.branch_prefix.as_str())
5291 .unwrap_or(branch);
5292 if let Some(rest) = entry.strip_prefix("pr-") {
5293 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
5294 }
5295 if entry.starts_with("issue-") || entry.starts_with("split-") {
5299 let text = self.gh_try(&[
5300 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
5301 ]);
5302 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5303 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
5304 }
5305 false
5306 }
5307}
5308
5309pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5321 match probe() {
5322 Ok(value) => Ok(value),
5323 Err(first) => {
5324 logdim!("retrying a worktree probe after: {}", first.last_line());
5325 probe()
5326 }
5327 }
5328}
5329
5330pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5336 let root = std::fs::canonicalize(cwd)
5337 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5338 let mut files = BTreeMap::new();
5339 let mut visited = BTreeSet::new();
5340 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5341 Ok(AttributeState { files })
5342}
5343
5344fn collect_attribute_files(
5345 root: &Path,
5346 repository: &Path,
5347 prefix: &Path,
5348 visited: &mut BTreeSet<PathBuf>,
5349 files: &mut BTreeMap<PathBuf, [u8; 32]>,
5350) -> Result<()> {
5351 let canonical = std::fs::canonicalize(repository)
5352 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5353 if !visited.insert(canonical) {
5354 bail!("submodule recursion revisited {}", repository.display());
5355 }
5356 let entries = index_entries(repository)?;
5357 let mut paths: BTreeSet<PathBuf> = entries
5358 .iter()
5359 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5360 .map(|entry| entry.path.clone())
5361 .collect();
5362 paths.extend(untracked_attribute_paths(repository)?);
5368 for path in paths {
5369 let from_root = prefix.join(&path);
5370 let state = attribute_file_fingerprint(&root.join(&from_root))?;
5371 files.insert(from_root, state);
5372 }
5373 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5374 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5375 continue;
5376 };
5377 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5378 }
5379 Ok(())
5380}
5381
5382pub(crate) fn uncertain_worktree_change(
5385 cwd: &Path,
5386 message: impl Into<String>,
5387) -> crate::error::SparError {
5388 let message = message.into();
5389 let marker = write_recovery_marker(cwd, &message);
5390 let note = match marker {
5391 Ok(path) => format!(" Recovery marker: {}.", path.display()),
5392 Err(e) => format!(
5393 " A recovery marker could not be written: {}.",
5394 e.last_line()
5395 ),
5396 };
5397 crate::error::SparError::uncertain_write(format!("{message}{note}"))
5398}
5399
5400fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5401 use std::sync::atomic::{AtomicU32, Ordering};
5402 static NEXT: AtomicU32 = AtomicU32::new(0);
5403 for _ in 0..1000 {
5404 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5405 let path = cwd.join(format!(
5406 "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5407 std::process::id()
5408 ));
5409 let mut options = OpenOptions::new();
5410 options.write(true).create_new(true);
5411 #[cfg(unix)]
5412 {
5413 use std::os::unix::fs::OpenOptionsExt;
5414 options.mode(0o600);
5415 }
5416 match options.open(&path) {
5417 Ok(mut file) => {
5418 file.write_all(detail.as_bytes())
5419 .and_then(|_| file.write_all(b"\n"))
5420 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5421 return Ok(path);
5422 }
5423 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5424 Err(e) => {
5425 return Err(spar_err!(
5426 "could not create a recovery marker in {}: {e}",
5427 cwd.display()
5428 ))
5429 }
5430 }
5431 }
5432 bail!(
5433 "could not choose a free recovery marker name in {}",
5434 cwd.display()
5435 )
5436}
5437
5438fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5443 let mut argv = vec![
5444 "git".to_string(),
5445 "-c".to_string(),
5446 "maintenance.auto=false".to_string(),
5447 "-c".to_string(),
5448 "gc.auto=0".to_string(),
5449 ];
5450 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5451 argv
5452}
5453
5454fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5455 let mut argv = git_without_maintenance_argv(&[]);
5456 argv.extend([
5457 "-c".to_string(),
5458 "core.fsmonitor=".to_string(),
5459 "-c".to_string(),
5460 "commit.gpgsign=false".to_string(),
5461 "-c".to_string(),
5462 "core.hooksPath=/dev/null".to_string(),
5463 ]);
5464 argv.extend(args.iter().map(|arg| (*arg).to_string()));
5465 argv
5466}
5467
5468pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5475 let root = std::fs::canonicalize(cwd)
5476 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5477 let mut files = BTreeMap::new();
5478 let mut ignored = BTreeSet::new();
5479 let mut visited = BTreeSet::new();
5480 collect_untracked_files(
5481 &root,
5482 &root,
5483 Path::new(""),
5484 &mut visited,
5485 &mut files,
5486 &mut ignored,
5487 )?;
5488 Ok(IgnoredState { files, ignored })
5489}
5490
5491fn collect_untracked_files(
5492 root: &Path,
5493 repository: &Path,
5494 prefix: &Path,
5495 visited: &mut BTreeSet<PathBuf>,
5496 files: &mut BTreeMap<PathBuf, UntrackedFile>,
5497 ignored: &mut BTreeSet<PathBuf>,
5498) -> Result<()> {
5499 let canonical = std::fs::canonicalize(repository)
5500 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5501 if !visited.insert(canonical.clone()) {
5502 bail!("submodule recursion revisited {}", canonical.display());
5503 }
5504 let listed = run_git_bytes(
5508 repository,
5509 &["ls-files", "--others", "--exclude-standard", "-z"],
5510 )?;
5511 if !listed.is_empty() && !listed.ends_with(&[0]) {
5512 bail!(
5513 "git returned an unterminated untracked-file list for {}",
5514 repository.display()
5515 );
5516 }
5517
5518 for raw in listed
5519 .split(|byte| *byte == 0)
5520 .filter(|raw| !raw.is_empty())
5521 {
5522 let (relative, nested) = untracked_record(raw, "untracked")?;
5523 let from_root = prefix.join(&relative);
5524 if is_spar_own_path(&from_root) {
5525 continue;
5526 }
5527 let absolute = root.join(&from_root);
5528 let fingerprint = if nested {
5529 nested_repository_fingerprint(&absolute)?
5530 } else {
5531 ignored_file_fingerprint(&absolute)?
5532 };
5533 if files.insert(from_root.clone(), fingerprint).is_some() {
5534 bail!(
5535 "git returned the untracked path more than once: {:?}",
5536 from_root
5537 );
5538 }
5539 }
5540
5541 let ignored_listed = run_git_bytes(
5542 repository,
5543 &[
5544 "ls-files",
5545 "--others",
5546 "--ignored",
5547 "--exclude-standard",
5548 "-z",
5549 ],
5550 )?;
5551 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5552 bail!(
5553 "git returned an unterminated ignored-file list for {}",
5554 repository.display()
5555 );
5556 }
5557 for raw in ignored_listed
5563 .split(|byte| *byte == 0)
5564 .filter(|raw| !raw.is_empty())
5565 {
5566 let (relative, nested) = untracked_record(raw, "ignored")?;
5567 let from_root = prefix.join(relative);
5568 if is_spar_own_path(&from_root) {
5569 continue;
5570 }
5571 if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5572 let absolute = root.join(&from_root);
5573 let fingerprint = if nested {
5574 nested_repository_fingerprint(&absolute)?
5575 } else {
5576 ignored_file_fingerprint(&absolute)?
5577 };
5578 slot.insert(fingerprint);
5579 }
5580 if !ignored.insert(from_root.clone()) {
5581 bail!(
5582 "git returned the ignored path more than once: {:?}",
5583 from_root
5584 );
5585 }
5586 }
5587
5588 for link in gitlinks(repository)? {
5589 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5590 continue;
5591 };
5592 collect_untracked_files(
5593 root,
5594 &submodule,
5595 &prefix.join(&link.path),
5596 visited,
5597 files,
5598 ignored,
5599 )?;
5600 }
5601 Ok(())
5602}
5603
5604fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5605 let argv = git_without_automation_argv(args);
5606 proc::run_bytes(
5607 &argv,
5608 &ExecOpts::new()
5609 .cwd(cwd)
5610 .timeout_secs(30)
5611 .stop_descendants(true),
5612 )
5613}
5614
5615fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5616 let argv = git_without_automation_argv(args);
5617 proc::run(
5618 &argv,
5619 &ExecOpts::new()
5620 .cwd(cwd)
5621 .timeout_secs(30)
5622 .stop_descendants(true),
5623 )
5624}
5625
5626fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5627 let path = path.to_str().ok_or_else(|| {
5628 spar_err!(
5629 "cannot verify filtered content for a non-UTF-8 path in {}",
5630 cwd.display()
5631 )
5632 })?;
5633 let path_arg = format!("--path={path}");
5634 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5635 Ok(Sha256::digest(bytes).into())
5636}
5637
5638fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5639 let relative = path_from_git_bytes(raw)?;
5640 if relative.is_absolute()
5641 || relative.components().any(|component| {
5642 matches!(
5643 component,
5644 std::path::Component::ParentDir
5645 | std::path::Component::RootDir
5646 | std::path::Component::Prefix(_)
5647 )
5648 })
5649 {
5650 bail!("git returned an unsafe {kind} path: {:?}", relative);
5651 }
5652 Ok(relative)
5653}
5654
5655fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5664 let nested = raw.last() == Some(&b'/');
5665 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5666 if trimmed.is_empty() {
5667 bail!("git returned an empty {kind} path");
5668 }
5669 Ok((safe_git_path(trimmed, kind)?, nested))
5670}
5671
5672fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5673 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5674 if !listed.is_empty() && !listed.ends_with(&[0]) {
5675 bail!(
5676 "git returned an unterminated index listing for {}",
5677 cwd.display()
5678 );
5679 }
5680 let mut entries = Vec::new();
5681 for record in listed
5682 .split(|byte| *byte == 0)
5683 .filter(|record| !record.is_empty())
5684 {
5685 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5686 bail!(
5687 "git returned a malformed index record for {}",
5688 cwd.display()
5689 );
5690 };
5691 let header = &record[..tab];
5692 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5693 if fields.len() != 3 {
5694 bail!(
5695 "git returned a malformed index header for {}",
5696 cwd.display()
5697 );
5698 }
5699 if fields[2] != b"0" {
5700 continue;
5701 }
5702 let mode = std::str::from_utf8(fields[0])
5703 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5704 .to_string();
5705 let oid = std::str::from_utf8(fields[1])
5706 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5707 .to_string();
5708 entries.push(IndexEntry {
5709 path: safe_git_path(&record[tab + 1..], "index")?,
5710 mode,
5711 oid,
5712 });
5713 }
5714 Ok(entries)
5715}
5716
5717fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
5719 let listed = run_git_bytes(
5720 cwd,
5721 &[
5722 "ls-files",
5723 "--others",
5724 "--exclude-standard",
5725 "-z",
5726 "--",
5727 ".gitattributes",
5728 ":(glob)**/.gitattributes",
5729 ],
5730 )?;
5731 if !listed.is_empty() && !listed.ends_with(&[0]) {
5732 bail!(
5733 "git returned an unterminated attribute-file listing for {}",
5734 cwd.display()
5735 );
5736 }
5737 listed
5738 .split(|byte| *byte == 0)
5739 .filter(|record| !record.is_empty())
5740 .map(|raw| safe_git_path(raw, "attribute"))
5741 .collect()
5742}
5743
5744fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5745 if !untracked_attribute_paths(cwd)?.is_empty() {
5746 return Ok(true);
5747 }
5748
5749 let index = index_entries(cwd)?
5750 .into_iter()
5751 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5752 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5753 .collect::<BTreeMap<_, _>>();
5754 let head = tree_entries(cwd, "HEAD")?
5755 .into_iter()
5756 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5757 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5758 .collect::<BTreeMap<_, _>>();
5759 if index != head {
5760 return Ok(true);
5761 }
5762
5763 let effective = check_attributes(cwd, index.keys().cloned())?;
5764 let config = CheckoutConfig::read(cwd)?;
5765 for (path, (_mode, oid)) in index {
5766 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5767 return Ok(true);
5768 };
5769 let attributes = effective
5770 .get(&path)
5771 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5772 if allows_expected_crlf(&config, attributes)? {
5773 if worktree.mode == "120000" {
5774 return Ok(true);
5775 }
5776 let (normalized, every_lf_was_crlf) =
5777 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5778 if !every_lf_was_crlf || normalized != oid {
5779 return Ok(true);
5780 }
5781 } else if worktree.raw_oid != oid {
5782 return Ok(true);
5783 }
5784 }
5785 Ok(false)
5786}
5787
5788fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5789 Ok(index_entries(cwd)?
5790 .into_iter()
5791 .filter(|entry| entry.mode == "160000")
5792 .map(|entry| Gitlink {
5793 path: entry.path,
5794 oid: entry.oid,
5795 })
5796 .collect())
5797}
5798
5799fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5800 let mut tracked = BTreeMap::new();
5801 for entry in index_entries(cwd)? {
5802 if entry.mode == "160000" {
5803 continue;
5804 }
5805 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5806 tracked.insert(
5807 entry.path,
5808 TrackedEntry {
5809 index_mode: entry.mode,
5810 index_oid: entry.oid,
5811 worktree,
5812 },
5813 );
5814 }
5815 Ok(tracked)
5816}
5817
5818fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5819 let metadata = match std::fs::symlink_metadata(path) {
5820 Ok(metadata) => metadata,
5821 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5822 Err(e) => {
5823 return Err(spar_err!(
5824 "could not inspect tracked file {}: {e}",
5825 path.display()
5826 ))
5827 }
5828 };
5829 let mut fingerprint = Sha256::new();
5830 if metadata.file_type().is_symlink() {
5831 let target = std::fs::read_link(path)
5832 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5833 let bytes = os_str_bytes(target.as_os_str())?;
5834 fingerprint.update(b"symlink\0");
5835 fingerprint.update(&bytes);
5836 let content = Sha256::digest(&bytes).into();
5837 return Ok(Some(WorktreeFile {
5838 mode: "120000".to_string(),
5839 #[cfg(unix)]
5840 permissions: 0,
5841 raw_oid: git_blob_oid(oid_len, &bytes)?,
5842 fingerprint: fingerprint.finalize().into(),
5843 content,
5844 }));
5845 }
5846 if !metadata.is_file() {
5847 bail!("tracked path {} is not a file or symlink", path.display());
5848 }
5849
5850 let mut options = OpenOptions::new();
5851 options.read(true);
5852 #[cfg(unix)]
5853 {
5854 use std::os::unix::fs::OpenOptionsExt;
5855 options.custom_flags(libc::O_NOFOLLOW);
5856 }
5857 let mut file = options
5858 .open(path)
5859 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5860 let before = file
5861 .metadata()
5862 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5863 let mode = tracked_file_mode(&before);
5864 #[cfg(unix)]
5865 let permissions = {
5866 use std::os::unix::fs::MetadataExt;
5867 before.mode() & 0o7777
5868 };
5869 fingerprint.update(b"file\0");
5870 fingerprint.update(mode.as_bytes());
5871 #[cfg(unix)]
5872 fingerprint.update(permissions.to_le_bytes());
5873 fingerprint.update(before.len().to_le_bytes());
5874 let mut content = Sha256::new();
5875 let header = format!("blob {}\0", before.len());
5876 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5877 let mut buf = [0u8; 64 * 1024];
5878 loop {
5879 let read = file
5880 .read(&mut buf)
5881 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5882 if read == 0 {
5883 break;
5884 }
5885 fingerprint.update(&buf[..read]);
5886 content.update(&buf[..read]);
5887 object.update(&buf[..read]);
5888 }
5889 let after = file
5890 .metadata()
5891 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5892 if before.len() != after.len()
5893 || before.modified().ok() != after.modified().ok()
5894 || before.permissions() != after.permissions()
5895 {
5896 bail!(
5897 "tracked file {} changed while it was being inspected",
5898 path.display()
5899 );
5900 }
5901 let current = std::fs::symlink_metadata(path)
5902 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5903 if !same_file(&after, ¤t) {
5904 bail!(
5905 "tracked file {} was replaced while it was being inspected",
5906 path.display()
5907 );
5908 }
5909 Ok(Some(WorktreeFile {
5910 mode,
5911 #[cfg(unix)]
5912 permissions,
5913 raw_oid: object.finish(),
5914 fingerprint: fingerprint.finalize().into(),
5915 content: content.finalize().into(),
5916 }))
5917}
5918
5919fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5920 let metadata = std::fs::symlink_metadata(path)
5921 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5922 let mut digest = Sha256::new();
5923 if metadata.file_type().is_symlink() {
5924 digest.update(b"symlink\0");
5925 let target = std::fs::read_link(path)
5926 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5927 digest.update(os_str_bytes(target.as_os_str())?);
5928 return Ok(digest.finalize().into());
5929 }
5930 if !metadata.is_file() {
5931 bail!("attribute path {} is not a file or symlink", path.display());
5932 }
5933 let mut options = OpenOptions::new();
5934 options.read(true);
5935 #[cfg(unix)]
5936 {
5937 use std::os::unix::fs::OpenOptionsExt;
5938 options.custom_flags(libc::O_NOFOLLOW);
5939 }
5940 let mut file = options
5941 .open(path)
5942 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5943 let before = file
5944 .metadata()
5945 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5946 digest.update(b"file\0");
5947 let mut buf = [0u8; 64 * 1024];
5948 loop {
5949 let read = file
5950 .read(&mut buf)
5951 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5952 if read == 0 {
5953 break;
5954 }
5955 digest.update(&buf[..read]);
5956 }
5957 let after = file
5958 .metadata()
5959 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5960 let current = std::fs::symlink_metadata(path)
5961 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5962 if before.len() != after.len()
5963 || before.modified().ok() != after.modified().ok()
5964 || !same_file(&after, ¤t)
5965 {
5966 bail!(
5967 "attribute file {} changed while it was being inspected",
5968 path.display()
5969 );
5970 }
5971 Ok(digest.finalize().into())
5972}
5973
5974enum ObjectHasher {
5975 Sha1(Sha1),
5976 Sha256(Sha256),
5977}
5978
5979impl ObjectHasher {
5980 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5981 let mut hasher = match oid_len {
5982 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5983 64 => Self::Sha256(Sha256::new()),
5984 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5985 };
5986 hasher.update(header);
5987 Ok(hasher)
5988 }
5989
5990 fn update(&mut self, bytes: &[u8]) {
5991 match self {
5992 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5993 Self::Sha256(hasher) => hasher.update(bytes),
5994 }
5995 }
5996
5997 fn finish(self) -> String {
5998 let bytes = match self {
5999 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
6000 Self::Sha256(hasher) => hasher.finalize().to_vec(),
6001 };
6002 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
6003 }
6004}
6005
6006fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
6007 let header = format!("blob {}\0", bytes.len());
6008 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
6009 hasher.update(bytes);
6010 Ok(hasher.finish())
6011}
6012
6013fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
6014 let mut first = open_regular_file(path)?;
6015 let first_before = first
6016 .metadata()
6017 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6018 let mut raw_len = 0u64;
6019 let mut crlf_pairs = 0u64;
6020 let mut previous_was_cr = false;
6021 let mut every_lf_was_crlf = true;
6022 let mut buf = [0u8; 64 * 1024];
6023 loop {
6024 let read = first
6025 .read(&mut buf)
6026 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6027 if read == 0 {
6028 break;
6029 }
6030 raw_len = raw_len
6031 .checked_add(read as u64)
6032 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
6033 for byte in &buf[..read] {
6034 if *byte == b'\n' {
6035 if previous_was_cr {
6036 crlf_pairs += 1;
6037 } else {
6038 every_lf_was_crlf = false;
6039 }
6040 }
6041 previous_was_cr = *byte == b'\r';
6042 }
6043 }
6044 let first_after = first
6045 .metadata()
6046 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6047 let current = std::fs::symlink_metadata(path)
6048 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6049 if raw_len != first_before.len()
6050 || !stable_file_metadata(&first_before, &first_after)
6051 || !stable_file_metadata(&first_after, ¤t)
6052 {
6053 bail!(
6054 "tracked file {} changed while line endings were inspected",
6055 path.display()
6056 );
6057 }
6058
6059 let normalized_len = raw_len
6060 .checked_sub(crlf_pairs)
6061 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
6062 let header = format!("blob {normalized_len}\0");
6063 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6064 let mut second = open_regular_file(path)?;
6065 let second_before = second
6066 .metadata()
6067 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6068 if !stable_file_metadata(&first_after, &second_before) {
6069 bail!(
6070 "tracked file {} changed between line-ending checks",
6071 path.display()
6072 );
6073 }
6074 let mut pending_cr = false;
6075 loop {
6076 let read = second
6077 .read(&mut buf)
6078 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6079 if read == 0 {
6080 break;
6081 }
6082 for byte in &buf[..read] {
6083 if pending_cr {
6084 if *byte == b'\n' {
6085 object.update(b"\n");
6086 pending_cr = false;
6087 continue;
6088 }
6089 object.update(b"\r");
6090 pending_cr = false;
6091 }
6092 if *byte == b'\r' {
6093 pending_cr = true;
6094 } else {
6095 object.update(std::slice::from_ref(byte));
6096 }
6097 }
6098 }
6099 if pending_cr {
6100 object.update(b"\r");
6101 }
6102 let second_after = second
6103 .metadata()
6104 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6105 let current = std::fs::symlink_metadata(path)
6106 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6107 if !stable_file_metadata(&second_before, &second_after)
6108 || !stable_file_metadata(&second_after, ¤t)
6109 {
6110 bail!(
6111 "tracked file {} changed while line endings were hashed",
6112 path.display()
6113 );
6114 }
6115 Ok((object.finish(), every_lf_was_crlf))
6116}
6117
6118fn open_regular_file(path: &Path) -> Result<std::fs::File> {
6119 let mut options = OpenOptions::new();
6120 options.read(true);
6121 #[cfg(unix)]
6122 {
6123 use std::os::unix::fs::OpenOptionsExt;
6124 options.custom_flags(libc::O_NOFOLLOW);
6125 }
6126 let file = options
6127 .open(path)
6128 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6129 let metadata = file
6130 .metadata()
6131 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6132 if !metadata.is_file() {
6133 bail!("tracked path {} is not a regular file", path.display());
6134 }
6135 Ok(file)
6136}
6137
6138fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6139 if !same_file(left, right)
6140 || left.len() != right.len()
6141 || left.modified().ok() != right.modified().ok()
6142 || left.permissions() != right.permissions()
6143 {
6144 return false;
6145 }
6146 #[cfg(unix)]
6147 {
6148 use std::os::unix::fs::MetadataExt;
6149 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
6150 }
6151 #[cfg(not(unix))]
6152 {
6153 left.created().ok() == right.created().ok()
6154 }
6155}
6156
6157const ATTRIBUTE_BATCH: usize = 1000;
6164
6165fn check_attributes(
6166 cwd: &Path,
6167 paths: impl IntoIterator<Item = PathBuf>,
6168) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6169 let paths = paths.into_iter().collect::<BTreeSet<_>>();
6170 if paths.is_empty() {
6171 return Ok(BTreeMap::new());
6172 }
6173 let ordered = paths.into_iter().collect::<Vec<_>>();
6174 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6175 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
6176 values.extend(check_attribute_batch(cwd, batch)?);
6177 }
6178 Ok(values)
6179}
6180
6181fn check_attribute_batch(
6182 cwd: &Path,
6183 batch: &[PathBuf],
6184) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6185 const NAMES: [&str; 6] = [
6186 "filter",
6187 "working-tree-encoding",
6188 "ident",
6189 "text",
6190 "eol",
6191 "crlf",
6192 ];
6193 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
6194 let mut input = String::new();
6195 for path in &paths {
6196 let path = path.to_str().ok_or_else(|| {
6197 spar_err!(
6198 "cannot inspect attributes for a non-UTF-8 path in {}",
6199 cwd.display()
6200 )
6201 })?;
6202 input.push_str(path);
6203 input.push('\0');
6204 }
6205 let argv = git_without_automation_argv(&[
6206 "check-attr",
6207 "-z",
6208 "--cached",
6209 "--stdin",
6210 "filter",
6211 "working-tree-encoding",
6212 "ident",
6213 "text",
6214 "eol",
6215 "crlf",
6216 ]);
6217 let output = proc::run_bytes(
6218 &argv,
6219 &ExecOpts::new()
6220 .cwd(cwd)
6221 .timeout_secs(30)
6222 .stdin(input)
6223 .stop_descendants(true),
6224 )?;
6225 if !output.is_empty() && !output.ends_with(&[0]) {
6226 bail!(
6227 "git returned an unterminated attribute result for {}",
6228 cwd.display()
6229 );
6230 }
6231 let fields = output
6232 .split(|byte| *byte == 0)
6233 .filter(|field| !field.is_empty())
6234 .collect::<Vec<_>>();
6235 if fields.len() != paths.len() * NAMES.len() * 3 {
6236 bail!(
6237 "git returned an unexpected attribute result for {}",
6238 cwd.display()
6239 );
6240 }
6241 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6242 for record in fields.chunks_exact(3) {
6243 let path = safe_git_path(record[0], "attribute")?;
6244 if !paths.contains(&path) {
6245 bail!(
6246 "git returned attributes for the wrong path in {}",
6247 cwd.display()
6248 );
6249 }
6250 let name = std::str::from_utf8(record[1])
6251 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
6252 let value = std::str::from_utf8(record[2])
6253 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
6254 values
6255 .entry(path)
6256 .or_default()
6257 .insert(name.to_string(), value.to_string());
6258 }
6259 if paths.iter().any(|path| {
6260 values
6261 .get(path)
6262 .is_none_or(|attributes| attributes.len() != NAMES.len())
6263 }) {
6264 bail!(
6265 "git omitted an attribute result for a tracked path in {}",
6266 cwd.display()
6267 );
6268 }
6269 Ok(values)
6270}
6271
6272fn attribute_is_active(value: Option<&String>) -> bool {
6273 !matches!(
6274 value.map(String::as_str),
6275 None | Some("unspecified") | Some("unset")
6276 )
6277}
6278
6279fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
6280 attribute_is_active(values.get("filter"))
6281 || attribute_is_active(values.get("working-tree-encoding"))
6282}
6283
6284struct CheckoutConfig {
6293 autocrlf: Option<String>,
6294 eol: Option<String>,
6295 symlinks: Option<bool>,
6296}
6297
6298impl CheckoutConfig {
6299 fn read(cwd: &Path) -> Result<Self> {
6300 Ok(Self {
6301 autocrlf: git_config_value(cwd, "core.autocrlf")?,
6302 eol: git_config_value(cwd, "core.eol")?,
6303 symlinks: git_config_bool(cwd, "core.symlinks")?,
6304 })
6305 }
6306
6307 fn autocrlf_is_true(&self) -> bool {
6308 self.autocrlf.as_ref().is_some_and(|value| {
6309 matches!(
6310 value.to_ascii_lowercase().as_str(),
6311 "true" | "yes" | "on" | "1"
6312 )
6313 })
6314 }
6315
6316 fn eol_is(&self, wanted: &str) -> bool {
6317 self.eol
6318 .as_ref()
6319 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6320 }
6321}
6322
6323fn path_has_ambiguous_transform(
6324 config: &CheckoutConfig,
6325 values: &BTreeMap<String, String>,
6326) -> Result<bool> {
6327 if path_has_external_transform(values)
6328 || attribute_is_active(values.get("ident"))
6329 || attribute_is_active(values.get("crlf"))
6330 {
6331 return Ok(true);
6332 }
6333 let text = values.get("text").map(String::as_str);
6334 let eol = values.get("eol").map(String::as_str);
6335 if text == Some("auto") {
6336 return Ok(true);
6337 }
6338 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6339 || !matches!(
6340 eol,
6341 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6342 )
6343 {
6344 return Ok(true);
6345 }
6346 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6347 return Ok(config.autocrlf_is_true());
6348 }
6349 Ok(false)
6350}
6351
6352fn allows_expected_crlf(
6353 config: &CheckoutConfig,
6354 values: &BTreeMap<String, String>,
6355) -> Result<bool> {
6356 if path_has_external_transform(values)
6357 || attribute_is_active(values.get("ident"))
6358 || attribute_is_active(values.get("crlf"))
6359 {
6360 return Ok(false);
6361 }
6362 let text = values.get("text").map(String::as_str);
6363 let eol = values.get("eol").map(String::as_str);
6364 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6365 return Ok(false);
6366 }
6367 if eol == Some("crlf") {
6368 return Ok(true);
6369 }
6370 if text != Some("set") {
6371 return Ok(false);
6372 }
6373 if let Some(autocrlf) = config.autocrlf.as_deref() {
6374 match autocrlf.to_ascii_lowercase().as_str() {
6375 "true" | "yes" | "on" | "1" => return Ok(true),
6376 "input" => return Ok(false),
6377 _ => {}
6378 }
6379 }
6380 if config.eol_is("crlf") {
6381 return Ok(true);
6382 }
6383 #[cfg(windows)]
6384 if config.eol.is_none() || config.eol_is("native") {
6385 return Ok(true);
6386 }
6387 Ok(false)
6388}
6389
6390fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6391 let argv = git_without_automation_argv(&["config", "--get", key]);
6392 let output = proc::exec(
6393 &argv,
6394 &ExecOpts::new()
6395 .cwd(cwd)
6396 .timeout_secs(30)
6397 .check(false)
6398 .stop_descendants(true),
6399 )?;
6400 match output.code {
6401 0 => Ok(Some(output.stdout.trim().to_string())),
6402 1 => Ok(None),
6403 _ => bail!(
6404 "could not read Git configuration in {}: {}",
6405 cwd.display(),
6406 output.stderr.trim()
6407 ),
6408 }
6409}
6410
6411fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6412 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6413 let output = proc::exec(
6414 &argv,
6415 &ExecOpts::new()
6416 .cwd(cwd)
6417 .timeout_secs(30)
6418 .check(false)
6419 .stop_descendants(true),
6420 )?;
6421 match output.code {
6422 0 if output.stdout.trim() == "true" => Ok(Some(true)),
6423 0 if output.stdout.trim() == "false" => Ok(Some(false)),
6424 0 => bail!(
6425 "git returned an invalid boolean for {key} in {}",
6426 cwd.display()
6427 ),
6428 1 => Ok(None),
6429 _ => bail!(
6430 "could not read Git configuration in {}: {}",
6431 cwd.display(),
6432 output.stderr.trim()
6433 ),
6434 }
6435}
6436
6437#[cfg(unix)]
6438fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6439 use std::os::unix::fs::PermissionsExt;
6440 if metadata.permissions().mode() & 0o111 == 0 {
6441 "100644".to_string()
6442 } else {
6443 "100755".to_string()
6444 }
6445}
6446
6447#[cfg(not(unix))]
6448fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6449 "100644".to_string()
6450}
6451
6452fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6453 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6454 if !listed.is_empty() && !listed.ends_with(&[0]) {
6455 bail!(
6456 "git returned an unterminated tree listing for {}",
6457 cwd.display()
6458 );
6459 }
6460 let mut entries = Vec::new();
6461 for record in listed
6462 .split(|byte| *byte == 0)
6463 .filter(|record| !record.is_empty())
6464 {
6465 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6466 bail!("git returned a malformed tree record for {}", cwd.display());
6467 };
6468 let fields = record[..tab]
6469 .split(|byte| *byte == b' ')
6470 .collect::<Vec<_>>();
6471 if fields.len() != 3 {
6472 bail!("git returned a malformed tree header for {}", cwd.display());
6473 }
6474 let mode = std::str::from_utf8(fields[0])
6475 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6476 .to_string();
6477 let oid = std::str::from_utf8(fields[2])
6478 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6479 .to_string();
6480 entries.push(IndexEntry {
6481 path: safe_git_path(&record[tab + 1..], "tree")?,
6482 mode,
6483 oid,
6484 });
6485 }
6486 Ok(entries)
6487}
6488
6489fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6490 Ok(tree_entries(cwd, "HEAD")?
6491 .into_iter()
6492 .filter(|entry| entry.mode == "160000")
6493 .map(|entry| (entry.path, entry.oid))
6494 .collect())
6495}
6496
6497fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6498 let head = head_gitlinks(cwd)?;
6499 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6500 .into_iter()
6501 .map(|link| (link.path, link.oid))
6502 .collect();
6503 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6504 paths.extend(index.keys().cloned());
6505 Ok(paths
6506 .into_iter()
6507 .filter(|path| head.get(path) != index.get(path))
6508 .collect())
6509}
6510
6511fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6512 let path = parent.join(relative);
6513 let metadata = match std::fs::symlink_metadata(&path) {
6514 Ok(metadata) => metadata,
6515 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6516 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6517 };
6518 if !metadata.is_dir() {
6519 bail!("the gitlink at {} is not a directory", path.display());
6520 }
6521 let canonical = std::fs::canonicalize(&path)
6522 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6523 if canonical != path {
6524 bail!(
6525 "the gitlink at {} resolves through a symlink",
6526 path.display()
6527 );
6528 }
6529 if !path.join(".git").exists() {
6530 let empty = std::fs::read_dir(&path)
6531 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6532 .next()
6533 .is_none();
6534 if empty {
6535 return Ok(None);
6536 }
6537 bail!(
6538 "the uninitialized gitlink at {} contains local files",
6539 path.display()
6540 );
6541 }
6542 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6543 if inside.trim() != "true" {
6544 bail!("the gitlink at {} is not a worktree", path.display());
6545 }
6546 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6547 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6548 spar_err!(
6549 "could not resolve the gitlink top level at {}: {e}",
6550 path.display()
6551 )
6552 })?;
6553 if top != canonical {
6554 bail!(
6555 "the gitlink at {} belongs to a different worktree",
6556 path.display()
6557 );
6558 }
6559 Ok(Some(canonical))
6560}
6561
6562fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6576 let root = std::fs::canonicalize(cwd)
6577 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6578 let mut allowed = BTreeSet::from([root.join(".git")]);
6579 let mut checkouts = BTreeSet::new();
6580 let mut repositories = vec![root.clone()];
6581 let mut visited = BTreeSet::new();
6582 while let Some(repository) = repositories.pop() {
6583 let canonical = std::fs::canonicalize(&repository)
6584 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6585 if !visited.insert(canonical.clone()) {
6586 bail!("submodule recursion revisited {}", canonical.display());
6587 }
6588 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6589 let (path, nested) = untracked_record(&raw, "untracked")?;
6590 if nested {
6591 checkouts.insert(canonical.join(path));
6592 }
6593 }
6594 for link in gitlinks(&canonical)? {
6595 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6596 continue;
6597 };
6598 allowed.insert(submodule.join(".git"));
6599 repositories.push(submodule);
6600 }
6601 }
6602
6603 let scan_root = root.clone();
6607 let mut directories = vec![root];
6608 while let Some(directory) = directories.pop() {
6609 let entries = match std::fs::read_dir(&directory) {
6610 Ok(entries) => entries,
6611 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6612 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6613 };
6614 for entry in entries {
6615 let entry = match entry {
6616 Ok(entry) => entry,
6617 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6618 Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6619 };
6620 let path = entry.path();
6621 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6622 continue;
6623 }
6624 if entry.file_name() == OsStr::new(".git") {
6625 if !allowed.contains(&path) {
6626 return Ok(Some(path));
6627 }
6628 continue;
6629 }
6630 let kind = match entry.file_type() {
6631 Ok(kind) => kind,
6632 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6633 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6634 };
6635 if kind.is_dir() && !checkouts.contains(&path) {
6636 directories.push(path);
6637 }
6638 }
6639 }
6640 Ok(None)
6641}
6642
6643pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6644 if let Some(path) = unexpected_nested_git_entry(cwd)? {
6645 bail!(
6646 "the worktree contains a Git entry at {} that Git does not report as a nested \
6647 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6648 prove they are unchanged.",
6649 path.display()
6650 );
6651 }
6652 let root = std::fs::canonicalize(cwd)
6653 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6654 let mut repositories = BTreeMap::new();
6655 let mut visited = BTreeSet::new();
6656 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
6657 Ok(GitState { repositories })
6658}
6659
6660fn collect_git_state(
6661 repository: &Path,
6662 prefix: &Path,
6663 visited: &mut BTreeSet<PathBuf>,
6664 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
6665) -> Result<()> {
6666 let canonical = std::fs::canonicalize(repository)
6667 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6668 if !visited.insert(canonical.clone()) {
6669 bail!("submodule recursion revisited {}", canonical.display());
6670 }
6671 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6672 let head = head.trim().to_string();
6673 if head.is_empty() {
6674 bail!("git returned an empty head for {}", repository.display());
6675 }
6676 let unsafe_index_flags = unsafe_index_flags(repository)?;
6677 let tracked = tracked_entries(repository)?;
6678 let gitlinks = gitlinks(repository)?;
6679 if repositories
6680 .insert(
6681 prefix.to_path_buf(),
6682 RepositoryState {
6683 head,
6684 unsafe_index_flags,
6685 tracked,
6686 gitlinks: gitlinks
6687 .iter()
6688 .map(|link| (link.path.clone(), link.oid.clone()))
6689 .collect(),
6690 },
6691 )
6692 .is_some()
6693 {
6694 bail!("Git state contains duplicate repository path {:?}", prefix);
6695 }
6696
6697 for link in gitlinks {
6698 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6699 continue;
6700 };
6701 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6702 }
6703 Ok(())
6704}
6705
6706fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6707 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6708 if !listed.is_empty() && !listed.ends_with(&[0]) {
6709 bail!(
6710 "git returned an unterminated index-flag listing for {}",
6711 cwd.display()
6712 );
6713 }
6714 let mut unsafe_records = Vec::new();
6715 for record in listed
6716 .split(|byte| *byte == 0)
6717 .filter(|record| !record.is_empty())
6718 {
6719 if record.len() < 3 || record[1] != b' ' {
6720 bail!(
6721 "git returned a malformed index-flag record for {}",
6722 cwd.display()
6723 );
6724 }
6725 if record[0] != b'H' {
6726 unsafe_records.extend_from_slice(record);
6727 unsafe_records.push(0);
6728 }
6729 }
6730 Ok(unsafe_records)
6731}
6732
6733pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6734 safe_git_state(cwd).map(|_| ())
6735}
6736
6737pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6738 let state = git_state(cwd)?;
6739 if let Some((path, _repository)) = state
6740 .repositories
6741 .iter()
6742 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6743 {
6744 let label = if path.as_os_str().is_empty() {
6745 cwd.to_path_buf()
6746 } else {
6747 cwd.join(path)
6748 };
6749 bail!(
6750 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6751 SPAR cannot prove the working files are unchanged, so it was kept.",
6752 label.display()
6753 );
6754 }
6755 Ok(state)
6756}
6757
6758fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6759 repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
6760}
6761
6762fn repository_has_recoverable_work_with(
6763 cwd: &Path,
6764 include_ignored: bool,
6765 disposable: Disposable,
6766) -> Result<bool> {
6767 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6768 return Ok(true);
6769 }
6770 let mut visited = BTreeSet::new();
6771 repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
6772}
6773
6774fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6775 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6776 let git_dir = PathBuf::from(git_dir.trim());
6777 let git_dir = if git_dir.is_absolute() {
6778 git_dir
6779 } else {
6780 cwd.join(git_dir)
6781 };
6782 let git_dir = std::fs::canonicalize(&git_dir)
6783 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6784 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6785 Ok(_) => return Ok(true),
6786 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6787 Err(error) => {
6788 return Err(spar_err!(
6789 "could not inspect per-worktree configuration in {}: {error}",
6790 git_dir.display()
6791 ))
6792 }
6793 }
6794
6795 let orig_head = git_dir.join("ORIG_HEAD");
6796 match std::fs::symlink_metadata(&orig_head) {
6797 Ok(metadata) if metadata.is_file() => {
6798 let oid = std::fs::read_to_string(&orig_head)
6799 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6800 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6801 return Ok(true);
6802 };
6803 if !commit_has_shared_ref(cwd, &commit)? {
6804 return Ok(true);
6805 }
6806 }
6807 Ok(_) => return Ok(true),
6808 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6809 Err(error) => {
6810 return Err(spar_err!(
6811 "could not inspect {}: {error}",
6812 orig_head.display()
6813 ))
6814 }
6815 }
6816
6817 let edit_message = git_dir.join("COMMIT_EDITMSG");
6818 match std::fs::symlink_metadata(&edit_message) {
6819 Ok(metadata) if metadata.is_file() => {
6820 let draft = std::fs::read(&edit_message)
6821 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6822 if draft != head_commit_message(cwd)? {
6823 return Ok(true);
6824 }
6825 }
6826 Ok(_) => return Ok(true),
6827 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6828 Err(error) => {
6829 return Err(spar_err!(
6830 "could not inspect {}: {error}",
6831 edit_message.display()
6832 ))
6833 }
6834 }
6835
6836 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6837 return Ok(true);
6838 }
6839
6840 let local_refs = run_git_bytes(
6841 cwd,
6842 &[
6843 "for-each-ref",
6844 "--format=%(refname)",
6845 "refs/worktree",
6846 "refs/bisect",
6847 "refs/rewritten",
6848 ],
6849 )?;
6850 if !local_refs.is_empty() {
6851 return Ok(true);
6852 }
6853
6854 for entry in std::fs::read_dir(&git_dir)
6855 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6856 {
6857 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6858 let known = matches!(
6859 entry.file_name().to_str(),
6860 Some(
6861 "HEAD"
6862 | "ORIG_HEAD"
6863 | "COMMIT_EDITMSG"
6864 | "commondir"
6865 | "gitdir"
6866 | "index"
6867 | "logs"
6868 | "refs"
6869 )
6870 );
6871 if !known {
6872 return Ok(true);
6873 }
6874 }
6875
6876 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6877 if !commit_has_shared_ref(cwd, head.trim())? {
6878 return Ok(true);
6879 }
6880 Ok(false)
6881}
6882
6883fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6884 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6885 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6886 bail!(
6887 "git returned a commit without a message separator in {}",
6888 cwd.display()
6889 );
6890 };
6891 Ok(commit[split + 2..].to_vec())
6892}
6893
6894fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6895 let metadata = match std::fs::symlink_metadata(logs) {
6896 Ok(metadata) => metadata,
6897 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6898 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6899 };
6900 if !metadata.is_dir() {
6901 return Ok(true);
6902 }
6903 let mut files = Vec::new();
6904 let mut directories = vec![logs.to_path_buf()];
6905 while let Some(directory) = directories.pop() {
6906 for entry in std::fs::read_dir(&directory)
6907 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6908 {
6909 let entry =
6910 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6911 let path = entry.path();
6912 let kind = entry
6913 .file_type()
6914 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6915 if kind.is_dir() {
6916 directories.push(path);
6917 } else if kind.is_file() {
6918 files.push(path);
6919 } else {
6920 return Ok(true);
6921 }
6922 }
6923 }
6924
6925 let mut commits = BTreeSet::new();
6926 for path in files {
6927 if !collect_reflog_commits(cwd, &path, &mut commits)? {
6928 return Ok(true);
6929 }
6930 }
6931 for commit in commits {
6932 if !commit_has_shared_ref(cwd, &commit)? {
6933 return Ok(true);
6934 }
6935 }
6936 Ok(false)
6937}
6938
6939fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6943 let common = common_git_dir(cwd)?;
6944 let reflog = common.join("logs").join(refname);
6945 let metadata = match std::fs::symlink_metadata(&reflog) {
6946 Ok(metadata) => metadata,
6947 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6948 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6949 };
6950 if !metadata.is_file() {
6951 return Ok(false);
6952 }
6953 let mut commits = BTreeSet::new();
6954 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6955 return Ok(false);
6956 }
6957 for commit in commits {
6958 if is_ancestor(cwd, &commit, durable_tip)?
6959 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6960 {
6961 continue;
6962 }
6963 return Ok(false);
6964 }
6965 Ok(true)
6966}
6967
6968fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6969 let file = std::fs::File::open(path)
6970 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6971 for line in std::io::BufReader::new(file).lines() {
6972 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6973 let mut fields = line.splitn(3, ' ');
6974 let Some(old) = fields.next() else {
6975 return Ok(false);
6976 };
6977 let Some(new) = fields.next() else {
6978 return Ok(false);
6979 };
6980 if fields.next().is_none() {
6981 return Ok(false);
6982 }
6983 for oid in [old, new] {
6984 if oid.bytes().all(|byte| byte == b'0') {
6985 continue;
6986 }
6987 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6988 return Ok(false);
6989 };
6990 commits.insert(commit);
6991 }
6992 }
6993 Ok(true)
6994}
6995
6996fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6997 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6998 let path = PathBuf::from(raw.trim());
6999 let path = if path.is_absolute() {
7000 path
7001 } else {
7002 cwd.join(path)
7003 };
7004 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
7005}
7006
7007fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
7008 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
7009 let output = proc::exec(
7010 &argv,
7011 &ExecOpts::new()
7012 .cwd(cwd)
7013 .timeout_secs(30)
7014 .check(false)
7015 .stop_descendants(true),
7016 )?;
7017 match output.code {
7018 0 => Ok(true),
7019 1 => Ok(false),
7020 _ => bail!("{}", proc::failure_message(&argv, &output)),
7021 }
7022}
7023
7024fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
7025 let commit = format!("{oid}^{{commit}}");
7026 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
7027 let output = proc::exec(
7028 &argv,
7029 &ExecOpts::new()
7030 .cwd(cwd)
7031 .timeout_secs(30)
7032 .check(false)
7033 .stop_descendants(true),
7034 )?;
7035 if output.code != 0 {
7036 return Ok(None);
7037 }
7038 let oid = output.stdout.trim();
7039 if oid.is_empty() {
7040 return Ok(None);
7041 }
7042 Ok(Some(oid.to_string()))
7043}
7044
7045fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
7046 commit_has_shared_ref_except(cwd, oid, None)
7047}
7048
7049fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
7050 let contains = format!("--contains={oid}");
7051 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
7052 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
7053 !record.is_empty()
7054 && !record.starts_with(b"refs/worktree/")
7055 && !record.starts_with(b"refs/bisect/")
7056 && !record.starts_with(b"refs/rewritten/")
7057 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
7058 }))
7059}
7060
7061fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
7076 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
7077 for raw in &ordinary {
7078 let (path, _) = untracked_record(raw, "untracked")?;
7079 if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
7080 continue;
7081 }
7082 return Ok(true);
7083 }
7084 let listed = untracked_listing(
7085 cwd,
7086 &[
7087 "ls-files",
7088 "--others",
7089 "--ignored",
7090 "--exclude-standard",
7091 "-z",
7092 ],
7093 )?;
7094 for raw in listed {
7095 let (path, nested) = untracked_record(&raw, "ignored")?;
7096 if nested {
7100 return Ok(true);
7101 }
7102 if disposable == Disposable::BuildOutput && !is_generated_artifact(&path) {
7103 return Ok(true);
7104 }
7105 }
7106 Ok(false)
7107}
7108
7109fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
7110 let listed = run_git_bytes(cwd, args)?;
7111 if !listed.is_empty() && !listed.ends_with(&[0]) {
7112 bail!(
7113 "git returned an unterminated untracked-file list for {}",
7114 cwd.display()
7115 );
7116 }
7117 Ok(listed
7118 .split(|byte| *byte == 0)
7119 .filter(|raw| !raw.is_empty())
7120 .map(|raw| raw.to_vec())
7121 .collect())
7122}
7123
7124fn repository_has_recoverable_work_inner(
7125 cwd: &Path,
7126 include_ignored: bool,
7127 disposable: Disposable,
7128 visited: &mut BTreeSet<PathBuf>,
7129) -> Result<bool> {
7130 let canonical = std::fs::canonicalize(cwd)
7131 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7132 if !visited.insert(canonical.clone()) {
7133 bail!("submodule recursion revisited {}", canonical.display());
7134 }
7135 if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
7136 return Ok(true);
7137 }
7138 if !unsafe_index_flags(cwd)?.is_empty() {
7139 return Ok(true);
7140 }
7141 if attributes_may_be_modified(cwd)? {
7142 return Ok(true);
7143 }
7144 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
7145 return Ok(true);
7146 }
7147 if include_ignored {
7148 let index = index_entries(cwd)?
7149 .into_iter()
7150 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7151 .collect::<BTreeMap<_, _>>();
7152 let head = tree_entries(cwd, "HEAD")?
7153 .into_iter()
7154 .map(|entry| (entry.path, (entry.mode, entry.oid)))
7155 .collect::<BTreeMap<_, _>>();
7156 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
7157 return Ok(true);
7158 }
7159 let tracked = tracked_entries(cwd)?;
7160 let effective = check_attributes(cwd, tracked.keys().cloned())?;
7161 let config = CheckoutConfig::read(cwd)?;
7162 for (path, entry) in tracked {
7163 let Some(worktree) = entry.worktree else {
7164 return Ok(true);
7165 };
7166 let attributes = effective.get(&path).ok_or_else(|| {
7167 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
7168 })?;
7169 if path_has_ambiguous_transform(&config, attributes)? {
7170 return Ok(true);
7171 }
7172 let symlink_file = entry.index_mode == "120000"
7173 && worktree.mode == "100644"
7174 && worktree.raw_oid == entry.index_oid
7175 && config.symlinks == Some(false);
7176 if worktree.mode != entry.index_mode && !symlink_file {
7177 return Ok(true);
7178 }
7179 if entry.index_mode == "120000" {
7180 if worktree.raw_oid != entry.index_oid {
7181 return Ok(true);
7182 }
7183 continue;
7184 }
7185 #[cfg(unix)]
7186 {
7187 let expected = if entry.index_mode == "100755" {
7188 0o755
7189 } else {
7190 0o644
7191 };
7192 if worktree.permissions != expected {
7193 return Ok(true);
7194 }
7195 }
7196 if allows_expected_crlf(&config, attributes)? {
7197 let (normalized, every_lf_was_crlf) =
7198 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
7199 if !every_lf_was_crlf || normalized != entry.index_oid {
7200 return Ok(true);
7201 }
7202 } else if worktree.raw_oid != entry.index_oid {
7203 return Ok(true);
7204 }
7205 }
7206 } else {
7207 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
7208 if !run_git_bytes(cwd, &args)?.is_empty() {
7209 return Ok(true);
7210 }
7211 }
7212 for link in gitlinks(cwd)? {
7213 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
7214 continue;
7215 };
7216 if include_ignored {
7221 return Ok(true);
7222 }
7223 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7224 if head.trim() != link.oid {
7225 return Ok(true);
7226 }
7227 if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
7228 {
7229 return Ok(true);
7230 }
7231 }
7232 Ok(false)
7233}
7234
7235pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
7236 repository_has_recoverable_work(cwd, false)
7237}
7238
7239fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
7240 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
7241 Ok(!run_git_bytes(cwd, &args)?.is_empty())
7242}
7243
7244#[cfg(unix)]
7245fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7246 use std::os::unix::ffi::OsStringExt;
7247 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
7248}
7249
7250#[cfg(not(unix))]
7251fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7252 String::from_utf8(raw.to_vec())
7253 .map(PathBuf::from)
7254 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
7255}
7256
7257fn vanished_untracked_file() -> UntrackedFile {
7273 UntrackedFile {
7274 kind: 0,
7275 len: 0,
7276 modified: None,
7277 created: None,
7278 readonly: false,
7279 symlink_target: None,
7280 #[cfg(unix)]
7281 device: 0,
7282 #[cfg(unix)]
7283 inode: 0,
7284 #[cfg(unix)]
7285 mode: 0,
7286 #[cfg(unix)]
7287 change_seconds: 0,
7288 #[cfg(unix)]
7289 change_nanoseconds: 0,
7290 }
7291}
7292
7293fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
7294 let metadata = match std::fs::symlink_metadata(path) {
7295 Ok(metadata) => metadata,
7296 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7297 Err(e) => {
7298 return Err(spar_err!(
7299 "could not inspect the nested repository at {}: {e}",
7300 path.display()
7301 ))
7302 }
7303 };
7304 if !metadata.is_dir() {
7305 bail!(
7306 "git reported {} as a nested repository, but it is not a directory",
7307 path.display()
7308 );
7309 }
7310 if std::fs::symlink_metadata(path.join(".git")).is_err() {
7311 bail!(
7312 "git reported {} as a nested repository, but it has no Git entry",
7313 path.display()
7314 );
7315 }
7316 #[cfg(unix)]
7317 {
7318 use std::os::unix::fs::MetadataExt;
7319 Ok(UntrackedFile {
7320 kind: 3,
7321 len: 0,
7322 modified: None,
7323 created: metadata.created().ok(),
7324 readonly: metadata.permissions().readonly(),
7325 symlink_target: None,
7326 device: metadata.dev(),
7327 inode: metadata.ino(),
7328 mode: metadata.mode(),
7329 change_seconds: 0,
7330 change_nanoseconds: 0,
7331 })
7332 }
7333 #[cfg(not(unix))]
7334 {
7335 Ok(UntrackedFile {
7336 kind: 3,
7337 len: 0,
7338 modified: None,
7339 created: metadata.created().ok(),
7340 readonly: metadata.permissions().readonly(),
7341 symlink_target: None,
7342 })
7343 }
7344}
7345
7346fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7347 let metadata = match std::fs::symlink_metadata(path) {
7348 Ok(metadata) => metadata,
7349 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7350 Err(e) => {
7351 return Err(spar_err!(
7352 "could not inspect untracked file {}: {e}",
7353 path.display()
7354 ))
7355 }
7356 };
7357 let kind = if metadata.file_type().is_symlink() {
7358 2
7359 } else if metadata.is_file() {
7360 1
7361 } else {
7362 bail!(
7363 "untracked path {} is not a regular file or symlink",
7364 path.display()
7365 );
7366 };
7367 let symlink_target = if kind == 2 {
7368 match std::fs::read_link(path) {
7369 Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7370 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7371 return Ok(vanished_untracked_file())
7372 }
7373 Err(e) => {
7374 return Err(spar_err!(
7375 "could not read untracked symlink {}: {e}",
7376 path.display()
7377 ))
7378 }
7379 }
7380 } else {
7381 None
7382 };
7383 #[cfg(unix)]
7384 {
7385 use std::os::unix::fs::MetadataExt;
7386 Ok(UntrackedFile {
7387 kind,
7388 len: metadata.len(),
7389 modified: metadata.modified().ok(),
7390 created: metadata.created().ok(),
7391 readonly: metadata.permissions().readonly(),
7392 symlink_target,
7393 device: metadata.dev(),
7394 inode: metadata.ino(),
7395 mode: metadata.mode(),
7396 change_seconds: metadata.ctime(),
7397 change_nanoseconds: metadata.ctime_nsec(),
7398 })
7399 }
7400 #[cfg(not(unix))]
7401 {
7402 Ok(UntrackedFile {
7403 kind,
7404 len: metadata.len(),
7405 modified: metadata.modified().ok(),
7406 created: metadata.created().ok(),
7407 readonly: metadata.permissions().readonly(),
7408 symlink_target,
7409 })
7410 }
7411}
7412
7413#[cfg(unix)]
7414fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7415 use std::os::unix::ffi::OsStrExt;
7416 Ok(value.as_bytes().to_vec())
7417}
7418
7419#[cfg(not(unix))]
7420fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7421 value
7422 .to_str()
7423 .map(|value| value.as_bytes().to_vec())
7424 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7425}
7426
7427#[cfg(unix)]
7428fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7429 use std::os::unix::fs::MetadataExt;
7430 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7431}
7432
7433#[cfg(not(unix))]
7434fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7435 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7436}
7437
7438#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7439pub struct BranchRecord {
7440 pub kind: String,
7441 pub number: i64,
7442}
7443
7444pub fn review_ref(number: i64) -> String {
7447 format!("refs/spar/pr-{number}")
7448}
7449
7450pub fn is_finished(state: &str) -> bool {
7451 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7452}
7453
7454pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7461 if let Some(parent) = path.parent() {
7462 std::fs::create_dir_all(parent)
7463 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7464 }
7465 let tmp = path.with_extension(format!(
7468 "{}.tmp",
7469 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7470 ));
7471 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7472 std::fs::rename(&tmp, path)
7473 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7474 Ok(())
7475}
7476
7477pub fn has_heading(text: &str, title: &str) -> bool {
7484 let wanted = title.trim().to_lowercase();
7485 if wanted.is_empty() {
7486 return false;
7487 }
7488 text.lines()
7489 .filter_map(|line| line.trim().strip_prefix("## "))
7490 .any(|heading| heading.trim().to_lowercase() == wanted)
7491}
7492
7493pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7496 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7497}
7498
7499#[derive(Deserialize)]
7501#[serde(rename_all = "camelCase")]
7502pub struct LinkedPrRow {
7503 number: i64,
7504 #[serde(default)]
7505 url: String,
7506 #[serde(default)]
7507 title: String,
7508 #[serde(default)]
7509 closing_issues_references: Vec<IssueRef>,
7510}
7511
7512pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7519 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7520}
7521
7522pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7524 rows.iter()
7525 .find(|row| {
7526 row.closing_issues_references
7527 .iter()
7528 .any(|linked| linked.number == issue)
7529 })
7530 .map(|row| PrRef {
7531 number: row.number,
7532 url: row.url.clone(),
7533 title: row.title.clone(),
7534 })
7535}
7536
7537pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7539 pick_linked_pr(&linked_pr_rows(json)?, issue)
7540}
7541
7542fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7549 if text.trim().is_empty() {
7550 return Err(spar_err!("GitHub returned no comment data"));
7551 }
7552 let mut out = Vec::new();
7553 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7554 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7555 Value::Array(items) => out.extend(items),
7556 _ => return Err(spar_err!("unexpected non-array comment page")),
7557 }
7558 }
7559 Ok(out)
7560}
7561
7562pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7563 let mut out = Vec::new();
7564 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7565 match value {
7566 Ok(Value::Array(items)) => out.extend(items),
7567 Ok(other) => out.push(other),
7568 Err(_) => break,
7569 }
7570 }
7571 out
7572}
7573
7574pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7582 body.trim_start().starts_with(STATE_MARKER)
7583 && !viewer.trim().is_empty()
7584 && author.trim().eq_ignore_ascii_case(viewer.trim())
7585 && parse_state_comment(body).is_some()
7586}
7587
7588pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7591 let marker = body.find(STATE_MARKER)?;
7592 let start = body[marker..].find('{')? + marker;
7593 let end = body.rfind('}')?;
7594 if end <= start {
7595 return None;
7596 }
7597 match serde_json::from_str(&body[start..=end]) {
7598 Ok(state) => Some(state),
7599 Err(_) => {
7600 logdim!("found a spar state comment but could not parse it");
7601 None
7602 }
7603 }
7604}
7605
7606fn choose_state_for_head(
7607 candidates: Vec<PersistedState>,
7608 actual_head: &str,
7609) -> Option<PersistedState> {
7610 let matching: Vec<PersistedState> = candidates
7611 .iter()
7612 .filter(|state| state.pr_head == actual_head)
7613 .cloned()
7614 .collect();
7615 if !matching.is_empty() {
7616 return newest_state(matching);
7617 }
7618 newest_state(candidates)
7619}
7620
7621fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7622 candidates.into_iter().reduce(|best, candidate| {
7623 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7624 candidate
7625 } else {
7626 best
7631 }
7632 })
7633}
7634
7635pub fn self_binary() -> Result<PathBuf> {
7641 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7642 let path = PathBuf::from(path);
7643 if proc::is_executable(&path) {
7644 return Ok(path);
7645 }
7646 bail!(
7647 "SPAR_SELF_BIN is set to {}, which is not executable",
7648 path.display()
7649 );
7650 }
7651 std::env::current_exe()
7652 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
7653}
7654
7655fn bool_env(value: bool) -> &'static str {
7656 if value {
7657 "1"
7658 } else {
7659 "0"
7660 }
7661}
7662
7663pub fn sh_quote(text: &str) -> String {
7666 format!("'{}'", text.replace('\'', r"'\''"))
7667}
7668
7669pub fn style_from_env() -> Style {
7672 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
7673 Style {
7674 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
7675 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
7676 ..Style::permissive()
7677 }
7678}
7679
7680#[cfg(test)]
7681mod tests {
7682 use super::*;
7683 use crate::config::StateStore;
7684 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
7685 use std::process::Command;
7686
7687 fn repo_for_titles() -> Repo {
7688 Repo {
7689 root: PathBuf::from("/nonexistent"),
7690 style: Style::default(),
7691 branch_prefix: String::new(),
7692 state_store: StateStore::Local,
7693 followups: crate::config::Followups::Issues,
7694 drafts: Drafts::Never,
7695 viewer: OnceLock::new(),
7696 checkpoints: Mutex::new(BTreeMap::new()),
7697 writes: WriteStats::default(),
7698 }
7699 }
7700
7701 #[test]
7702 fn write_results_accumulate_for_the_run() {
7703 let repo = repo_for_titles();
7704
7705 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
7706 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
7707
7708 assert_eq!(
7709 WriteSummary {
7710 attempted: 2,
7711 failed: 1,
7712 },
7713 repo.write_summary()
7714 );
7715 }
7716
7717 #[test]
7718 fn only_failed_write_preflights_join_the_summary() {
7719 let repo = repo_for_titles();
7720
7721 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
7722 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
7723
7724 assert_eq!(
7725 WriteSummary {
7726 attempted: 1,
7727 failed: 1,
7728 },
7729 repo.write_summary()
7730 );
7731 }
7732
7733 #[test]
7734 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
7735 let repo = repo_for_titles();
7736
7737 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
7738 assert_eq!(
7739 WriteSummary {
7740 attempted: 1,
7741 failed: 1,
7742 },
7743 repo.write_summary()
7744 );
7745 }
7746
7747 #[test]
7748 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7749 let mut repo = repo_for_titles();
7750 repo.followups = Followups::Local;
7751
7752 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7753 assert_eq!(WriteSummary::default(), repo.write_summary());
7754 }
7755
7756 #[test]
7757 fn a_failed_remote_state_read_stops_before_state_mutation() {
7758 let root = std::env::temp_dir().join(format!(
7759 "spar-state-preflight-{}-{}",
7760 std::process::id(),
7761 std::time::SystemTime::now()
7762 .duration_since(std::time::UNIX_EPOCH)
7763 .unwrap()
7764 .as_nanos()
7765 ));
7766 std::fs::create_dir_all(&root).unwrap();
7767 let _fixture = ReviewFixture { root: root.clone() };
7768 let mut repo = repo_for_titles();
7769 repo.root = root;
7770 repo.state_store = StateStore::Both;
7771 let state = PersistedState {
7772 version: 1,
7773 checkpoint: 4,
7774 round: 2,
7775 next_actor: "a".into(),
7776 status: Status::Pending,
7777 pr_head: "abc123".into(),
7778 ledger: Ledger::new(),
7779 filed: Vec::new(),
7780 open_findings: Vec::new(),
7781 disputes: Vec::new(),
7782 noted: Vec::new(),
7783 };
7784
7785 let error = repo
7786 .write_state_after_remote_read(
7787 7,
7788 &state,
7789 Err(crate::error::SparError::new("state comments unavailable")),
7790 )
7791 .unwrap_err();
7792
7793 assert!(error.to_string().contains("state comments unavailable"));
7794 assert!(!repo.state_path(7).exists());
7795 assert_eq!(0, repo.remembered_checkpoint(7));
7796 assert_eq!(
7797 WriteSummary {
7798 attempted: 1,
7799 failed: 1,
7800 },
7801 repo.write_summary()
7802 );
7803 }
7804
7805 #[test]
7806 fn only_known_build_and_cache_directories_are_generated_artifacts() {
7807 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7808 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7809 assert!(is_generated_artifact(Path::new(
7810 "package/node_modules/dependency/file.js"
7811 )));
7812 assert!(!is_generated_artifact(Path::new(
7813 "distribution/required-package.js"
7814 )));
7815 assert!(!is_generated_artifact(Path::new(
7816 "generated/required-fixture.txt"
7817 )));
7818 assert!(!is_generated_artifact(Path::new("local.env")));
7819 }
7820
7821 struct ReviewFixture {
7822 root: PathBuf,
7823 }
7824
7825 impl Drop for ReviewFixture {
7826 fn drop(&mut self) {
7827 let _ = std::fs::remove_dir_all(&self.root);
7828 }
7829 }
7830
7831 fn test_git(cwd: &Path, args: &[&str]) -> String {
7832 let output = Command::new("git")
7833 .args(args)
7834 .current_dir(cwd)
7835 .output()
7836 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7837 assert!(
7838 output.status.success(),
7839 "git {args:?} failed: {}",
7840 String::from_utf8_lossy(&output.stderr)
7841 );
7842 String::from_utf8_lossy(&output.stdout).into_owned()
7843 }
7844
7845 fn review_fixture(
7846 tag: &str,
7847 number: i64,
7848 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7849 use std::sync::atomic::{AtomicU32, Ordering};
7850 static NEXT: AtomicU32 = AtomicU32::new(0);
7851 let id = NEXT.fetch_add(1, Ordering::Relaxed);
7852 let root =
7853 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7854 let origin = root.join("origin.git");
7855 let work = root.join("work");
7856 std::fs::create_dir_all(&origin).unwrap();
7857 std::fs::create_dir_all(&work).unwrap();
7858 test_git(&origin, &["init", "--bare", "-b", "main"]);
7859 test_git(&work, &["init", "-b", "main"]);
7860 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7861 test_git(&work, &["config", "user.name", "spar test"]);
7862 test_git(&work, &["config", "commit.gpgsign", "false"]);
7863 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7864 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7865 std::fs::write(work.join("README.md"), "seed\n").unwrap();
7866 std::fs::write(work.join("data.txt"), "old\n").unwrap();
7867 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7868 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7869 test_git(&work, &["add", "."]);
7870 test_git(&work, &["commit", "-m", "seed"]);
7871 test_git(
7872 &work,
7873 &["remote", "add", "origin", origin.to_str().unwrap()],
7874 );
7875 test_git(&work, &["push", "-u", "origin", "main"]);
7876 test_git(
7877 &work,
7878 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7879 );
7880 let cfg = crate::config::parse(
7881 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7882 )
7883 .unwrap();
7884 let repo = Repo::open(&work, &cfg).unwrap();
7885 let path = repo.worktree_for_pr_head(number).unwrap();
7886 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7887 (ReviewFixture { root }, repo, path, checkpoint)
7888 }
7889
7890 #[test]
7891 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7892 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7893
7894 repo.release_review_worktree_checked(901, &checkpoint)
7895 .unwrap();
7896
7897 assert!(!path.exists());
7898 }
7899
7900 #[test]
7901 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7902 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7903 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7904 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7905 test_git(&path, &["add", "recovery.txt"]);
7906 test_git(&path, &["commit", "-m", "recovery commit"]);
7907 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7908 test_git(&path, &["reset", "--hard", "main"]);
7909
7910 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7911 test_git(
7912 &path,
7913 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7914 );
7915 }
7916
7917 #[test]
7918 fn a_review_ref_reflog_only_commit_prevents_deletion() {
7919 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7920 let local_ref = review_ref(921);
7921 let original = test_git(&path, &["rev-parse", &local_ref]);
7922 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7923 let recovery = test_git(
7924 &path,
7925 &[
7926 "commit-tree",
7927 tree.trim(),
7928 "-p",
7929 original.trim(),
7930 "-m",
7931 "review ref recovery",
7932 ],
7933 );
7934 test_git(
7935 &path,
7936 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7937 );
7938 test_git(
7939 &path,
7940 &["update-ref", &local_ref, original.trim(), recovery.trim()],
7941 );
7942
7943 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7944 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7945 test_git(
7946 &path,
7947 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7948 );
7949 }
7950
7951 #[test]
7952 fn an_unpublished_commit_message_draft_is_recoverable() {
7953 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7954 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7955 let git_dir = if raw.is_absolute() {
7956 raw
7957 } else {
7958 path.join(raw)
7959 };
7960 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7961
7962 assert!(repository_has_recoverable_work(&path, true).unwrap());
7963 assert_eq!(
7964 "unique recovery draft\n",
7965 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7966 );
7967 }
7968
7969 #[test]
7970 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7971 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7972 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7973
7974 let error = repo
7975 .release_review_worktree_checked(902, &checkpoint)
7976 .unwrap_err();
7977
7978 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7979 assert!(error.to_string().contains("kept for recovery"), "{error}");
7980 assert_eq!(
7981 "recover me\n",
7982 std::fs::read_to_string(path.join("README.md")).unwrap()
7983 );
7984 repo.release_review_worktree(902);
7985 }
7986
7987 #[test]
7988 fn a_review_commit_is_retained_after_a_checked_read() {
7989 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7990 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7991 test_git(&path, &["add", "review-note.txt"]);
7992 test_git(&path, &["commit", "-m", "local review recovery"]);
7993 let head = test_git(&path, &["rev-parse", "HEAD"]);
7994
7995 let error = repo
7996 .release_review_worktree_checked(903, &checkpoint)
7997 .unwrap_err();
7998
7999 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8000 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
8001 assert_eq!(
8002 "recover me\n",
8003 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
8004 );
8005 repo.release_review_worktree(903);
8006 }
8007
8008 #[test]
8012 fn an_ignored_review_file_is_retained_after_a_checked_read() {
8013 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
8014 std::fs::create_dir_all(path.join("generated")).unwrap();
8015 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
8016
8017 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8018 .expect("its own output is not a change");
8019 let error = repo
8020 .release_review_worktree_checked(904, &checkpoint)
8021 .unwrap_err();
8022
8023 assert!(error.to_string().contains("recoverable"), "{error}");
8024 assert_eq!(
8025 "recover me\n",
8026 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
8027 );
8028 repo.release_review_worktree(904);
8029 }
8030
8031 #[test]
8032 fn a_preexisting_ignored_review_file_change_is_retained() {
8038 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
8039 std::fs::create_dir_all(path.join("generated")).unwrap();
8040 let ignored = path.join("generated/recovery.txt");
8041 std::fs::write(&ignored, "before\n").unwrap();
8042 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8043 std::fs::write(&ignored, "after!\n").unwrap();
8044
8045 let error = repo
8046 .release_review_worktree_checked(905, &checkpoint)
8047 .unwrap_err();
8048
8049 assert!(error.to_string().contains("recoverable"), "{error}");
8050 assert!(path.exists());
8051 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
8052 repo.release_review_worktree(905);
8053 }
8054
8055 #[test]
8056 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
8057 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
8058 std::fs::create_dir_all(path.join("generated")).unwrap();
8059 let ignored = path.join("generated/recovery.txt");
8060 std::fs::write(&ignored, "keep me\n").unwrap();
8061 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8062
8063 let error = repo
8064 .release_review_worktree_checked(906, &checkpoint)
8065 .unwrap_err();
8066
8067 assert!(error.to_string().contains("recoverable"), "{error}");
8068 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
8069 }
8070
8071 #[test]
8072 fn overwriting_a_preexisting_untracked_file_is_detected() {
8073 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
8074 let untracked = path.join("notes.txt");
8075 std::fs::write(&untracked, "before\n").unwrap();
8076 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8077 std::fs::write(&untracked, "after!\n").unwrap();
8078
8079 let error = repo
8080 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8081 .unwrap_err();
8082
8083 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8084 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
8085 }
8086
8087 #[test]
8088 fn an_assume_unchanged_edit_is_detected() {
8089 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
8090 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
8091 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
8092
8093 let error = repo
8094 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8095 .unwrap_err();
8096
8097 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8098 assert_eq!(
8099 "hidden\n",
8100 std::fs::read_to_string(path.join("README.md")).unwrap()
8101 );
8102 }
8103
8104 #[test]
8105 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
8106 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
8107 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8108 test_git(&path, &["add", "README.md"]);
8109 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8110
8111 let error = repo
8112 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8113 .unwrap_err();
8114
8115 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8116 assert_eq!(
8117 b"seed\r\n",
8118 std::fs::read(path.join("README.md")).unwrap().as_slice()
8119 );
8120 }
8121
8122 #[cfg(unix)]
8123 #[test]
8124 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
8125 use std::os::unix::fs::PermissionsExt;
8126
8127 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
8128 test_git(&path, &["config", "core.filemode", "false"]);
8129 let readme = path.join("README.md");
8130 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8131 permissions.set_mode(0o755);
8132 std::fs::set_permissions(&readme, permissions).unwrap();
8133 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8134
8135 let error = repo
8136 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8137 .unwrap_err();
8138
8139 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8140 assert_eq!(
8141 0o755,
8142 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8143 );
8144 }
8145
8146 #[test]
8147 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
8148 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
8149 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
8150 test_git(&path, &["add", ".gitattributes"]);
8151 test_git(&path, &["commit", "-m", "select data filter"]);
8152 let baseline = repo.worktree_baseline(&path).unwrap();
8153 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
8154
8155 assert!(repo
8156 .commit_pending_changes(&path, &baseline, "change data", "change data")
8157 .unwrap());
8158 let error = repo
8159 .refuse_unrepresented_tracked_changes(&path, &baseline)
8160 .unwrap_err();
8161
8162 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8163 assert_eq!(
8164 "secret: recover me\nnew\n",
8165 std::fs::read_to_string(path.join("data.txt")).unwrap()
8166 );
8167 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
8168 }
8169
8170 #[test]
8171 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
8172 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
8173 std::fs::create_dir_all(path.join("target")).unwrap();
8174 let untracked = path.join("target/user.yaml");
8175 std::fs::write(&untracked, "user data\n").unwrap();
8176 let baseline = repo.worktree_baseline(&path).unwrap();
8177 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8178
8179 assert!(repo
8180 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8181 .unwrap());
8182
8183 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
8184 assert_eq!(
8185 "?? target/user.yaml\n",
8186 test_git(&path, &["status", "--short", "--untracked-files=all"])
8187 );
8188 assert!(test_git(
8189 &path,
8190 &[
8191 "ls-tree",
8192 "-r",
8193 "--name-only",
8194 "HEAD",
8195 "--",
8196 "target/user.yaml"
8197 ]
8198 )
8199 .is_empty());
8200 }
8201
8202 #[test]
8203 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
8204 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
8205 std::fs::create_dir_all(path.join("target")).unwrap();
8206 let untracked = path.join("target/user.yaml");
8207 std::fs::write(&untracked, "before\n").unwrap();
8208 let baseline = repo.worktree_baseline(&path).unwrap();
8209 let before = test_git(&path, &["rev-parse", "HEAD"]);
8210 std::fs::write(&untracked, "after\n").unwrap();
8211 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8212
8213 let error = repo
8214 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8215 .unwrap_err();
8216
8217 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8218 assert!(error.to_string().contains("target/user.yaml"), "{error}");
8219 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8220 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
8221 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
8222 }
8223
8224 #[test]
8225 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
8226 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
8227 let baseline = repo.worktree_baseline(&path).unwrap();
8228 std::fs::create_dir_all(path.join("target")).unwrap();
8229 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
8230
8231 assert!(repo
8232 .commit_pending_changes(&path, &baseline, "add file", "add file")
8233 .unwrap());
8234
8235 assert_eq!(
8236 "new file\n",
8237 test_git(&path, &["show", "HEAD:target/new.txt"])
8238 );
8239 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8240 }
8241
8242 #[test]
8243 fn a_managed_commit_accepts_a_custom_output_rebuild() {
8244 let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
8245 exclude_paths(&repo, &["manager/public/"]);
8246 let output = path.join("manager/public/assets");
8247 std::fs::create_dir_all(&output).unwrap();
8248 std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
8249 std::fs::write(output.join("index.css"), "old styles\n").unwrap();
8250 let baseline = repo.worktree_baseline(&path).unwrap();
8251 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8252 std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
8253 std::fs::remove_file(output.join("old.js")).unwrap();
8254 std::fs::write(output.join("index.css"), "new styles\n").unwrap();
8255 std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
8256
8257 assert!(repo
8258 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8259 .unwrap());
8260 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8261 repo.refuse_changed_existing_untracked(&path, &baseline)
8262 .unwrap();
8263 repo.refuse_unrepresented_tracked_changes(&path, &baseline)
8264 .unwrap();
8265
8266 assert_eq!(
8267 "tracked change\n",
8268 test_git(&path, &["show", "HEAD:README.md"])
8269 );
8270 assert_eq!(
8271 "new source\n",
8272 test_git(&path, &["show", "HEAD:new-source.txt"])
8273 );
8274 assert!(test_git(
8275 &path,
8276 &[
8277 "ls-tree",
8278 "-r",
8279 "--name-only",
8280 "HEAD",
8281 "--",
8282 "manager/public"
8283 ]
8284 )
8285 .is_empty());
8286 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8287 assert!(!output.join("old.js").exists());
8288 assert_eq!(
8289 "new styles\n",
8290 std::fs::read_to_string(output.join("index.css")).unwrap()
8291 );
8292 assert!(repository_has_recoverable_work(&path, true).unwrap());
8293 repo.release_review_worktree(912);
8294 assert!(output.join("index-DUym-Rfj.js").exists());
8295 }
8296
8297 #[test]
8298 fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
8299 let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
8300 std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
8301 let baseline = repo.worktree_baseline(&path).unwrap();
8302 std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
8303 std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
8304
8305 let error = repo
8306 .commit_pending_changes(&path, &baseline, "change", "change")
8307 .unwrap_err();
8308
8309 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8310 assert!(error.to_string().contains("notes.txt"), "{error}");
8311 }
8312
8313 #[test]
8314 fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
8315 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
8316 let baseline = repo.worktree_baseline(&path).unwrap();
8317 let nested = path.join("generated/repro");
8318 std::fs::create_dir_all(&nested).unwrap();
8319 test_git(&nested, &["init"]);
8320 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8321
8322 assert!(repo
8323 .commit_pending_changes(&path, &baseline, "change", "change")
8324 .unwrap());
8325 assert!(!repo.has_uncommitted_changes(&path).unwrap());
8326 assert!(repository_has_recoverable_work(&path, true).unwrap());
8327 repo.release_review_worktree(927);
8328 assert!(nested.join(".git").exists());
8329 }
8330
8331 #[test]
8332 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
8333 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
8334 test_git(&path, &["config", "core.autocrlf", "true"]);
8335 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8336 assert_eq!(
8337 test_git(&path, &["hash-object", "README.md"]).trim(),
8338 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8339 );
8340
8341 assert!(repository_has_recoverable_work(&path, true).unwrap());
8342 assert_eq!(
8343 "seed\n",
8344 std::fs::read_to_string(path.join("README.md")).unwrap()
8345 );
8346 }
8347
8348 #[test]
8349 fn autocrlf_input_overrides_a_crlf_core_eol() {
8350 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8351 test_git(&path, &["config", "core.autocrlf", "input"]);
8352 test_git(&path, &["config", "core.eol", "crlf"]);
8353 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8354 assert_eq!(
8355 test_git(&path, &["hash-object", "README.md"]).trim(),
8356 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8357 );
8358
8359 assert!(repository_has_recoverable_work(&path, true).unwrap());
8360 assert_eq!(
8361 b"seed\r\n",
8362 std::fs::read(path.join("README.md")).unwrap().as_slice()
8363 );
8364 }
8365
8366 #[cfg(unix)]
8367 #[test]
8368 fn a_non_executable_permission_change_is_recoverable() {
8369 use std::os::unix::fs::PermissionsExt;
8370
8371 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8372 let readme = path.join("README.md");
8373 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8374 permissions.set_mode(0o600);
8375 std::fs::set_permissions(&readme, permissions).unwrap();
8376 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8377
8378 let error = repo
8379 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8380 .unwrap_err();
8381
8382 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8383 assert!(repository_has_recoverable_work(&path, true).unwrap());
8384 assert_eq!(
8385 0o600,
8386 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8387 );
8388 }
8389
8390 #[cfg(unix)]
8391 #[test]
8392 fn a_managed_commit_skips_signing_and_hooks() {
8393 use std::os::unix::fs::PermissionsExt;
8394
8395 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8396 let common = common_git_dir(&path).unwrap();
8397 let hook = common.join("hooks/pre-commit");
8398 let marker = fixture.root.join("hook-ran");
8399 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8400 std::fs::write(
8401 &hook,
8402 format!(
8403 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8404 sh_quote(marker.to_str().unwrap())
8405 ),
8406 )
8407 .unwrap();
8408 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8409 permissions.set_mode(0o755);
8410 std::fs::set_permissions(&hook, permissions).unwrap();
8411 test_git(&path, &["config", "commit.gpgsign", "true"]);
8412 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8413 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8414 test_git(&path, &["add", "managed.txt"]);
8415
8416 repo.commit_staged_changes(&path, "record managed change")
8417 .unwrap();
8418
8419 assert!(!marker.exists());
8420 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8421 }
8422
8423 #[test]
8424 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8425 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8426 std::fs::write(
8427 path.join(".gitattributes"),
8428 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8429 )
8430 .unwrap();
8431 test_git(&path, &["add", ".gitattributes"]);
8432 test_git(&path, &["commit", "-m", "select automatic text"]);
8433 test_git(&path, &["config", "core.autocrlf", "true"]);
8434
8435 assert!(repository_has_recoverable_work(&path, true).unwrap());
8436 }
8437
8438 #[test]
8439 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8440 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8441 std::fs::write(
8442 path.join(".gitattributes"),
8443 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8444 )
8445 .unwrap();
8446 test_git(&path, &["add", ".gitattributes"]);
8447 test_git(&path, &["commit", "-m", "select ident expansion"]);
8448 std::fs::write(path.join("README.md"), "seed\n").unwrap();
8449
8450 assert!(repository_has_recoverable_work(&path, true).unwrap());
8451 }
8452
8453 #[test]
8459 fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8460 let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8461 exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8462 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8463 std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8464 std::fs::write(
8465 path.join("bindings/uniffi-bindgen/Cargo.lock"),
8466 "# generated\n",
8467 )
8468 .unwrap();
8469
8470 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8471 .expect("a path the project ignores is the call's own output");
8472 }
8473
8474 #[test]
8475 fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8476 let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8477 exclude_paths(&repo, &["target/"]);
8478 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8479 std::fs::create_dir_all(path.join("target/package")).unwrap();
8480 std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8481
8482 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8483 .expect("an ignored attribute file governs only ignored paths");
8484 assert!(
8485 !repo.has_uncommitted_changes(&path).unwrap(),
8486 "ignored dependency attributes must not stop the next review"
8487 );
8488 }
8489
8490 #[test]
8491 fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
8492 let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
8493 std::fs::create_dir_all(path.join("source")).unwrap();
8494 std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
8495
8496 assert!(repo.has_uncommitted_changes(&path).unwrap());
8497 }
8498
8499 #[test]
8500 fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
8501 for source in ["repository", "nested", "local", "global"] {
8502 let (_fixture, repo, path, _initial) = review_fixture(source, 945);
8503 std::fs::create_dir_all(path.join("manager")).unwrap();
8504 match source {
8505 "repository" => {
8506 std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
8507 .unwrap();
8508 test_git(&path, &["add", ".gitignore"]);
8509 test_git(&path, &["commit", "-m", "ignore artifacts"]);
8510 }
8511 "nested" => {
8512 std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
8513 test_git(&path, &["add", "manager/.gitignore"]);
8514 test_git(&path, &["commit", "-m", "ignore artifacts"]);
8515 }
8516 "local" => exclude_paths(&repo, &["manager/scratch/"]),
8517 "global" => {
8518 let excludes = repo.root().parent().unwrap().join("global-ignore");
8519 std::fs::write(&excludes, "manager/scratch/\n").unwrap();
8520 test_git(
8521 &path,
8522 &["config", "core.excludesFile", excludes.to_str().unwrap()],
8523 );
8524 }
8525 _ => unreachable!(),
8526 }
8527 let output = path.join("manager/scratch");
8528 std::fs::create_dir_all(&output).unwrap();
8529 std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
8530 std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
8531 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8532 std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
8533 std::fs::remove_file(output.join(".DS_Store")).unwrap();
8534 for file in [
8535 ".gitattributes",
8536 "Cargo.lock",
8537 "font.woff2",
8538 "bundle.js",
8539 "cache.db",
8540 ] {
8541 std::fs::write(output.join(file), "generated\n").unwrap();
8542 }
8543 #[cfg(unix)]
8544 std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
8545 let nested = output.join("dependency");
8546 std::fs::create_dir_all(&nested).unwrap();
8547 test_git(&nested, &["init"]);
8548 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8549 .unwrap_or_else(|error| panic!("{source}: {error}"));
8550 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
8551
8552 let baseline = repo.worktree_baseline(&path).unwrap();
8553 std::fs::write(path.join("README.md"), "source fix\n").unwrap();
8554 std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
8555 std::fs::remove_file(output.join("Cargo.lock")).unwrap();
8556 std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
8557 std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
8558 assert!(repo
8559 .commit_pending_changes(&path, &baseline, "fix source", "fix source")
8560 .unwrap_or_else(|error| panic!("{source}: {error}")));
8561 assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
8562 assert!(test_git(
8563 &path,
8564 &[
8565 "ls-tree",
8566 "-r",
8567 "--name-only",
8568 "HEAD",
8569 "--",
8570 "manager/scratch"
8571 ]
8572 )
8573 .is_empty());
8574 assert!(
8575 repository_has_recoverable_work(&path, true).unwrap(),
8576 "{source}"
8577 );
8578 repo.release_review_worktree(945);
8579 assert_eq!(
8580 "LOCAL=updated\n",
8581 std::fs::read_to_string(output.join(".env.local")).unwrap()
8582 );
8583 assert!(nested.join(".git").exists(), "{source}");
8584 }
8585 }
8586
8587 #[test]
8591 fn a_recovery_marker_is_not_a_change_to_the_worktree() {
8592 let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
8593 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8594 let _ = uncertain_worktree_change(&path, "an earlier call was refused");
8595
8596 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8597 .expect("spar's own marker is not something a call did");
8598 }
8599
8600 #[test]
8601 fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
8602 let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
8603
8604 let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
8605 .expect("a path a build removed is not an unreadable worktree");
8606
8607 assert_eq!(vanished_untracked_file(), gone);
8608 }
8609
8610 #[test]
8613 fn a_vanished_path_is_not_recorded_as_absent() {
8614 let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
8615 std::fs::write(path.join("notes.txt"), "before\n").unwrap();
8616 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8617 std::fs::remove_file(path.join("notes.txt")).unwrap();
8618
8619 let error = repo
8620 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8621 .unwrap_err();
8622
8623 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8624 }
8625
8626 fn exclude_paths(repo: &Repo, lines: &[&str]) {
8627 use std::io::Write;
8628 let path = repo.root().join(".git").join("info").join("exclude");
8629 let mut file = std::fs::OpenOptions::new()
8630 .create(true)
8631 .append(true)
8632 .open(&path)
8633 .unwrap();
8634 for line in lines {
8635 writeln!(file, "{line}").unwrap();
8636 }
8637 }
8638
8639 #[test]
8640 fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
8641 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
8642 exclude_paths(&repo, &["dist/"]);
8643 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8644 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8645 let baseline = repo.worktree_baseline(&path).unwrap();
8646 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8647 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8648 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8649 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8650
8651 assert!(repo
8652 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8653 .unwrap());
8654 assert_eq!(
8655 "tracked change\n",
8656 std::fs::read_to_string(path.join("README.md")).unwrap()
8657 );
8658 }
8659
8660 #[test]
8661 fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
8662 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
8663 exclude_paths(&repo, &["dist/"]);
8664 std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8665 std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8666 std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
8667 let baseline = repo.worktree_baseline(&path).unwrap();
8668 std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8669 std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
8670 std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8671 std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8672
8673 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8674 repo.refuse_changed_existing_untracked(&path, &baseline)
8675 .unwrap();
8676 }
8677
8678 #[test]
8679 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
8680 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
8681 exclude_paths(&repo, &["dist/"]);
8682 std::fs::create_dir_all(path.join("dist")).unwrap();
8683 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8684 let baseline = repo.worktree_baseline(&path).unwrap();
8685 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8686 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8687
8688 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8689 repo.refuse_changed_existing_untracked(&path, &baseline)
8690 .unwrap();
8691 }
8692
8693 #[test]
8702 fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
8703 let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
8704 let held = repo.root().join(WORKTREE_DIR).join("issue-950");
8705 std::fs::create_dir_all(held.parent().unwrap()).unwrap();
8706 test_git(
8707 repo.root(),
8708 &[
8709 "worktree",
8710 "add",
8711 "-b",
8712 "issue-950",
8713 held.to_str().unwrap(),
8714 "main",
8715 ],
8716 );
8717
8718 let found = repo
8719 .worktree_holding("issue-950")
8720 .expect("the branch is checked out somewhere");
8721
8722 assert!(found.ends_with("issue-950"), "{}", found.display());
8723 assert_eq!(None, repo.worktree_holding("issue-951"));
8724 }
8725
8726 #[test]
8727 fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
8728 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
8729 exclude_paths(&repo, &["manager/public/assets/"]);
8730 std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
8731 let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
8732 std::fs::write(&bundle, "first build\n").unwrap();
8733 let baseline = repo.worktree_baseline(&path).unwrap();
8734 std::fs::write(&bundle, "second build\n").unwrap();
8735 std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
8736
8737 repo.refuse_read_only_leavings(&path, &baseline).unwrap();
8738 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8739 }
8740
8741 #[test]
8742 fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
8743 let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
8744 exclude_paths(&repo, &["dist/"]);
8745 let baseline = repo.worktree_baseline(&path).unwrap();
8746 std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
8747
8748 let error = repo
8749 .refuse_read_only_leavings(&path, &baseline)
8750 .unwrap_err();
8751
8752 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8753 assert!(error.to_string().contains("scratch.md"), "{error}");
8754 }
8755
8756 #[test]
8757 fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
8758 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
8759 exclude_paths(&repo, &["dist/", ".env.local"]);
8760 std::fs::create_dir_all(path.join("dist")).unwrap();
8761 let baseline = repo.worktree_baseline(&path).unwrap();
8762 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
8763 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
8764
8765 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8766 assert!(repository_has_recoverable_work(&path, true).unwrap());
8767 }
8768
8769 #[test]
8770 fn a_read_only_inspection_may_rebuild_generated_output() {
8771 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
8772 exclude_paths(&repo, &["dist/"]);
8773 std::fs::create_dir_all(path.join("dist")).unwrap();
8774 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8775 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8776 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8777 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8778
8779 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8780 .unwrap();
8781 }
8782
8783 #[test]
8784 fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
8785 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
8786 exclude_paths(&repo, &["dist/", ".env.local"]);
8787 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8788 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8789 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
8790
8791 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8792 .unwrap();
8793 }
8794
8795 #[test]
8796 fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
8797 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
8798 exclude_paths(&repo, &["dist/", ".env.local"]);
8799 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8800 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8801 std::fs::remove_file(path.join(".env.local")).unwrap();
8802
8803 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8804 .unwrap();
8805 }
8806
8807 #[test]
8808 fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
8809 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
8810 exclude_paths(&repo, &["dist/"]);
8811 std::fs::write(path.join("notes.md"), "before\n").unwrap();
8812 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8813 std::fs::write(path.join("notes.md"), "after\n").unwrap();
8814
8815 let error = repo
8816 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8817 .unwrap_err();
8818
8819 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8820 assert!(error.to_string().contains("notes.md"), "{error}");
8821 }
8822
8823 #[test]
8824 fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
8825 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
8826 exclude_paths(&repo, &["dist/", "local.env"]);
8827 std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
8828 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8829 std::fs::write(
8832 repo.root().join(".git").join("info").join("exclude"),
8833 "dist/\n",
8834 )
8835 .unwrap();
8836
8837 let error = repo
8838 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8839 .unwrap_err();
8840
8841 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8842 assert!(error.to_string().contains("local.env"), "{error}");
8843 }
8844
8845 #[test]
8846 fn a_read_only_inspection_names_the_tracked_file_that_moved() {
8847 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
8848 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8849 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8850
8851 let error = repo
8852 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8853 .unwrap_err();
8854
8855 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8856 assert!(error.to_string().contains("README.md"), "{error}");
8857 }
8858
8859 #[test]
8860 fn build_output_alone_does_not_keep_a_worktree() {
8861 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
8862 exclude_paths(&repo, &["target/", "dist/"]);
8863 std::fs::create_dir_all(path.join("target/debug")).unwrap();
8864 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8865 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
8866 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
8867
8868 assert!(!repository_has_recoverable_work(&path, true).unwrap());
8869 repo.release_review_worktree(933);
8870
8871 assert!(!path.exists());
8872 }
8873
8874 #[test]
8875 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
8876 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
8877 exclude_paths(&repo, &["target/", ".env.local"]);
8878 std::fs::create_dir_all(path.join("target/debug")).unwrap();
8879 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8880 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
8881
8882 assert!(repository_has_recoverable_work(&path, true).unwrap());
8883 repo.release_review_worktree(934);
8884
8885 assert_eq!(
8886 "TOKEN=keep me\n",
8887 std::fs::read_to_string(path.join(".env.local")).unwrap()
8888 );
8889 }
8890
8891 #[test]
8892 fn a_repository_nested_in_build_output_keeps_a_worktree() {
8893 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
8894 exclude_paths(&repo, &["node_modules/"]);
8895 let nested = path.join("node_modules/local-dep");
8896 std::fs::create_dir_all(&nested).unwrap();
8897 test_git(&nested, &["init"]);
8898 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
8899
8900 assert!(repository_has_recoverable_work(&path, true).unwrap());
8901 repo.release_review_worktree(935);
8902
8903 assert!(nested.join(".git").exists());
8904 }
8905
8906 #[test]
8907 fn an_ordinary_untracked_file_keeps_a_worktree() {
8908 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
8909 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
8910
8911 assert!(repository_has_recoverable_work(&path, true).unwrap());
8912 repo.release_review_worktree(936);
8913
8914 assert_eq!(
8915 "somebody's notes\n",
8916 std::fs::read_to_string(path.join("notes.md")).unwrap()
8917 );
8918 }
8919
8920 #[test]
8921 fn a_legacy_crlf_checkout_is_retained_conservatively() {
8922 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
8923 std::fs::write(
8924 path.join(".gitattributes"),
8925 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
8926 )
8927 .unwrap();
8928 test_git(&path, &["add", ".gitattributes"]);
8929 test_git(&path, &["commit", "-m", "select legacy line endings"]);
8930
8931 assert!(repository_has_recoverable_work(&path, true).unwrap());
8932 }
8933
8934 #[test]
8935 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
8936 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
8937 let nested = path.join("tracked");
8938 std::fs::create_dir_all(&nested).unwrap();
8939 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
8940 test_git(&path, &["add", "tracked/seed.txt"]);
8941 test_git(&path, &["commit", "-m", "add tracked directory"]);
8942 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8943 test_git(&nested, &["init"]);
8944
8945 let error = repo
8946 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8947 .unwrap_err();
8948
8949 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8950 assert!(error.to_string().contains("Git entry"), "{error}");
8951 assert!(nested.join(".git").exists());
8952 }
8953
8954 #[test]
8959 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
8960 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
8961 exclude_paths(&repo, &["target/"]);
8962 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8963 let repro = path.join("target/pr918-repro");
8964 std::fs::create_dir_all(&repro).unwrap();
8965 test_git(&repro, &["init"]);
8966 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
8967
8968 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8969 .unwrap();
8970
8971 assert!(repository_has_recoverable_work(&path, true).unwrap());
8972 assert!(repro.join(".git").exists());
8973 }
8974
8975 #[test]
8979 fn a_repository_inside_a_nested_checkout_is_left_alone() {
8980 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
8981 exclude_paths(&repo, &["target/"]);
8982 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8983 let repro = path.join("target/repro");
8984 let vendored = repro.join("vendor/dep");
8985 std::fs::create_dir_all(&vendored).unwrap();
8986 test_git(&repro, &["init"]);
8987 test_git(&vendored, &["init"]);
8988
8989 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8990 .unwrap();
8991
8992 assert!(vendored.join(".git").exists());
8993 }
8994
8995 #[test]
8996 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
8997 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
8998
8999 let state = ignored_untracked_state(repo.root()).unwrap();
9000
9001 let relative = path.strip_prefix(repo.root()).unwrap();
9002 assert!(
9003 state.files.contains_key(relative),
9004 "{:?}",
9005 state.files.keys().collect::<Vec<_>>()
9006 );
9007 assert!(state.is_ignored(relative));
9008 }
9009
9010 #[test]
9011 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
9012 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
9013 let baseline = repo.worktree_baseline(repo.root()).unwrap();
9014 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
9015 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
9016
9017 repo.refuse_new_ignored_files(repo.root(), &baseline)
9018 .unwrap();
9019 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
9020 .unwrap();
9021 }
9022
9023 #[test]
9024 fn deleting_a_resident_worktree_during_a_call_is_refused() {
9025 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
9026 let baseline = repo.worktree_baseline(repo.root()).unwrap();
9027 let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
9028 std::fs::remove_dir_all(&path).unwrap();
9029
9030 let read_error = repo
9031 .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
9032 .unwrap_err();
9033 assert!(
9034 read_error.to_string().contains("review-932"),
9035 "{read_error}"
9036 );
9037 let error = repo
9038 .refuse_new_ignored_files(repo.root(), &baseline)
9039 .unwrap_err();
9040
9041 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9042 assert!(error.to_string().contains("review-932"), "{error}");
9043 }
9044
9045 #[test]
9046 fn a_nested_repository_record_is_read_as_a_plain_path() {
9047 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
9048 assert_eq!(Path::new("vendor/checkout"), path);
9049 assert!(nested);
9050
9051 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
9052 assert_eq!(Path::new("vendor/notes.txt"), path);
9053 assert!(!nested);
9054
9055 assert!(untracked_record(b"/", "untracked").is_err());
9056 }
9057
9058 #[cfg(unix)]
9059 #[test]
9060 fn a_non_utf8_git_path_is_preserved_without_loss() {
9061 use std::os::unix::ffi::OsStrExt;
9062
9063 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
9064
9065 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
9066 }
9067
9068 #[test]
9069 fn guarded_merge_pins_the_reviewed_head() {
9070 let args = merge_pr_args("36", Some("abc123"), true);
9071 assert_eq!(
9072 vec![
9073 "pr",
9074 "merge",
9075 "36",
9076 "--squash",
9077 "--delete-branch",
9078 "--match-head-commit",
9079 "abc123"
9080 ],
9081 args
9082 );
9083 }
9084
9085 #[test]
9086 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
9087 let pr = PrRef {
9088 number: 7,
9089 url: "https://example.test/pull/7".into(),
9090 title: "part one".into(),
9091 };
9092 let result = reconcile_pr_creation(
9093 "split-34-1",
9094 Err(crate::error::SparError::new("connection lost")),
9095 Ok(Some(pr)),
9096 )
9097 .unwrap();
9098 assert_eq!(7, result.number);
9099 }
9100
9101 #[test]
9102 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
9103 let error = reconcile_pr_creation(
9104 "split-34-1",
9105 Err(crate::error::SparError::new("permission denied")),
9106 Ok(None),
9107 )
9108 .unwrap_err();
9109 assert!(error.to_string().contains("permission denied"), "{error}");
9110 }
9111
9112 #[test]
9113 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
9114 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
9115 assert!(pr_for_base(text, "split-34-2", "split-34-1")
9116 .unwrap()
9117 .is_none());
9118 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
9119 assert_eq!(7, found.number);
9120 }
9121
9122 #[test]
9123 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
9124 let result = reconcile_comment_post(
9125 34,
9126 "the summary",
9127 crate::error::SparError::new("connection lost"),
9128 Ok(vec![serde_json::json!({"body": "the summary"})]),
9129 );
9130 assert!(result.is_ok(), "{result:?}");
9131 }
9132
9133 #[test]
9134 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
9135 let error = reconcile_comment_post(
9136 34,
9137 "the summary",
9138 crate::error::SparError::new("connection lost"),
9139 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
9140 )
9141 .unwrap_err();
9142 assert_eq!("connection lost", error.to_string());
9143 }
9144
9145 #[test]
9146 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
9147 let error = reconcile_comment_post(
9148 34,
9149 "the summary",
9150 crate::error::SparError::new("connection lost"),
9151 Err(crate::error::SparError::new("comments unavailable")),
9152 )
9153 .unwrap_err();
9154 assert!(
9155 error.to_string().contains("could not be verified"),
9156 "{error}"
9157 );
9158 assert!(
9159 error.to_string().contains("comments unavailable"),
9160 "{error}"
9161 );
9162 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9163 assert!(!error.worth_retrying());
9164 }
9165
9166 #[test]
9167 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
9168 let result = reconcile_issue_edit(
9169 34,
9170 "wanted body",
9171 crate::error::SparError::new("connection lost"),
9172 Ok("wanted body".to_string()),
9173 );
9174 assert!(result.is_ok(), "{result:?}");
9175 }
9176
9177 #[test]
9178 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
9179 let error = reconcile_issue_edit(
9180 34,
9181 "wanted body",
9182 crate::error::SparError::new("connection lost"),
9183 Err(crate::error::SparError::new("issue unavailable")),
9184 )
9185 .unwrap_err();
9186 assert!(
9187 error.to_string().contains("could not be verified"),
9188 "{error}"
9189 );
9190 assert!(error.to_string().contains("issue unavailable"), "{error}");
9191 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9192 assert!(!error.worth_retrying());
9193 }
9194
9195 #[test]
9196 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
9197 let found = ExistingIssue {
9198 number: 101,
9199 url: "https://example.test/issues/101".into(),
9200 title: "child".into(),
9201 body: "body".into(),
9202 open: true,
9203 };
9204 let url = reconcile_issue_creation(
9205 "child",
9206 Err(crate::error::SparError::new("connection lost")),
9207 Ok(Some(found)),
9208 )
9209 .unwrap();
9210 assert_eq!("https://example.test/issues/101", url);
9211 }
9212
9213 #[test]
9214 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
9215 let error = reconcile_issue_creation(
9216 "child",
9217 Err(crate::error::SparError::new("permission denied")),
9218 Ok(None),
9219 )
9220 .unwrap_err();
9221 assert!(error.to_string().contains("permission denied"), "{error}");
9222 }
9223
9224 #[test]
9225 fn an_unverifiable_issue_creation_is_marked_uncertain() {
9226 let error = reconcile_issue_creation(
9227 "child",
9228 Err(crate::error::SparError::new("connection lost")),
9229 Err(crate::error::SparError::new("issues unavailable")),
9230 )
9231 .unwrap_err();
9232 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9233 assert!(!error.worth_retrying());
9234 }
9235
9236 #[test]
9237 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
9238 let result = reconcile_failed_split_push(
9239 "split-34-1",
9240 crate::error::SparError::new("connection lost"),
9241 Ok("abc123\n".into()),
9242 Ok("abc123\trefs/heads/split-34-1\n".into()),
9243 );
9244 assert!(result.is_ok(), "{result:?}");
9245 }
9246
9247 #[test]
9248 fn a_split_push_collision_is_definite_and_never_overwrites() {
9249 let error = reconcile_failed_split_push(
9250 "split-34-1",
9251 crate::error::SparError::new("lease rejected"),
9252 Ok("abc123\n".into()),
9253 Ok("def456\trefs/heads/split-34-1\n".into()),
9254 )
9255 .unwrap_err();
9256 assert!(!error.retain_worktree());
9257 assert!(
9258 error.to_string().contains("Nothing was overwritten"),
9259 "{error}"
9260 );
9261 }
9262
9263 #[test]
9264 fn an_unreadable_split_push_result_keeps_the_worktree() {
9265 let error = reconcile_failed_split_push(
9266 "split-34-1",
9267 crate::error::SparError::new("connection lost"),
9268 Ok("abc123\n".into()),
9269 Err(crate::error::SparError::new("origin unavailable")),
9270 )
9271 .unwrap_err();
9272 assert!(error.retain_worktree());
9273 assert!(error.to_string().contains("could not confirm"), "{error}");
9274 }
9275
9276 #[test]
9280 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
9281 let repo = repo_for_titles();
9282 for raw in [
9283 "Retry loop spins \u{2014} Retry-After parses to zero",
9284 "plain title",
9285 " spread over\nlines ",
9286 "\u{1F916} Generated with something",
9287 &format!("a \u{2014} {}", "very long title ".repeat(20)),
9288 &"x".repeat(300),
9289 &format!("{} \u{2014} end", "y".repeat(88)),
9290 &{
9295 let tail = "a\u{2014}b c\u{2014}d";
9296 let pad = Style::default().max_title_chars - tail.chars().count();
9297 format!("{}{tail}", "w".repeat(pad))
9298 },
9299 ] {
9300 let once = repo.clean_title(raw).unwrap();
9301 let twice = repo.clean_title(&once).unwrap();
9302 assert_eq!(once, twice, "not idempotent for {raw:?}");
9303 assert!(
9304 once.chars().count() <= repo.style.max_title_chars,
9305 "over budget: {once:?}"
9306 );
9307 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
9308 }
9309 }
9310
9311 #[test]
9312 fn a_title_with_an_em_dash_survives_as_readable_text() {
9313 let repo = repo_for_titles();
9314 assert_eq!(
9315 "Retry loop spins, Retry-After parses to zero",
9316 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
9317 .unwrap()
9318 );
9319 }
9320
9321 #[test]
9322 fn sh_quote_survives_a_quote() {
9323 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
9324 }
9325
9326 #[test]
9327 fn sh_quote_wraps_a_space() {
9328 assert_eq!(
9329 "'/Applications/My App/spar'",
9330 sh_quote("/Applications/My App/spar")
9331 );
9332 }
9333
9334 #[test]
9335 fn finished_states_are_recognised_case_insensitively() {
9336 assert!(is_finished("MERGED"));
9337 assert!(is_finished("closed"));
9338 assert!(!is_finished("OPEN"));
9339 assert!(!is_finished(""));
9340 }
9341
9342 fn state() -> PersistedState {
9343 PersistedState {
9344 version: 1,
9345 checkpoint: 0,
9346 round: 4,
9347 next_actor: "codex".into(),
9348 status: Status::Pending,
9349 pr_head: "abc123".into(),
9350 ledger: Ledger::new(),
9351 filed: vec![],
9352 open_findings: vec![Finding {
9353 severity: Severity::Blocking,
9354 title: "Unchecked error".into(),
9355 detail: "the failure is discarded".into(),
9356 file: "src/a.rs:12".into(),
9357 ..Finding::default()
9358 }],
9359 disputes: vec![Dispute {
9360 title: "Retry limit".into(),
9361 file: "src/net.rs".into(),
9362 reasoning: "the caller already bounds it".into(),
9363 }],
9364 noted: vec![Finding {
9365 severity: Severity::NonBlocking,
9366 title: "Timeout is fixed".into(),
9367 file: "src/config.rs".into(),
9368 ..Finding::default()
9369 }],
9370 }
9371 }
9372
9373 #[test]
9374 fn a_state_comment_round_trips() {
9375 let body = format!(
9376 "{STATE_MARKER}\n{}\n-->",
9377 serde_json::to_string(&state()).unwrap()
9378 );
9379 let back = parse_state_comment(&body).unwrap();
9380 assert_eq!(4, back.round);
9381 assert_eq!("codex", back.next_actor);
9382 assert_eq!("abc123", back.pr_head);
9383 assert_eq!("Unchecked error", back.open_findings[0].title);
9384 assert_eq!("src/net.rs", back.disputes[0].file);
9385 assert_eq!("Timeout is fixed", back.noted[0].title);
9386 }
9387
9388 #[test]
9389 fn old_state_without_new_lists_still_parses() {
9390 let body = format!(
9391 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
9392 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9393 );
9394 let back = parse_state_comment(&body).expect("old state");
9395 assert!(back.open_findings.is_empty());
9396 assert!(back.disputes.is_empty());
9397 assert!(back.noted.is_empty());
9398 assert!(back.pr_head.is_empty());
9399 assert_eq!(0, back.checkpoint);
9400 }
9401
9402 #[test]
9403 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
9404 let mut local = state();
9405 local.pr_head = "old".into();
9406 local.round = 9;
9407 let mut remote = state();
9408 remote.pr_head = "current".into();
9409 remote.round = 4;
9410
9411 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9412 assert_eq!("current", chosen.pr_head);
9413 assert_eq!(4, chosen.round);
9414 }
9415
9416 #[test]
9417 fn checkpoint_order_breaks_same_round_ties() {
9418 let mut local = state();
9419 local.pr_head = "current".into();
9420 local.round = 4;
9421 local.checkpoint = 8;
9422 let mut remote = local.clone();
9423 remote.checkpoint = 7;
9424 remote.open_findings.clear();
9425
9426 let chosen = choose_state_for_head(vec![local], "current").unwrap();
9427 assert_eq!(8, chosen.checkpoint);
9428
9429 let mut local = state();
9430 local.pr_head = "current".into();
9431 local.round = 4;
9432 local.checkpoint = 8;
9433 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
9434 assert_eq!(8, chosen.checkpoint);
9435 }
9436
9437 #[test]
9438 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
9439 let mut local = state();
9440 local.pr_head = "current".into();
9441 local.round = 4;
9442 local.open_findings.push(Finding {
9443 title: "local checkpoint".into(),
9444 ..Finding::default()
9445 });
9446 let mut remote = state();
9447 remote.pr_head = "current".into();
9448 remote.round = 4;
9449
9450 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9451 assert_eq!(
9452 "local checkpoint",
9453 chosen.open_findings.last().unwrap().title
9454 );
9455 }
9456
9457 #[test]
9459 fn the_state_block_is_an_html_comment() {
9460 let body = format!(
9461 "{STATE_MARKER}\n{}\n-->",
9462 serde_json::to_string(&state()).unwrap()
9463 );
9464 assert!(body.starts_with("<!--"));
9465 assert!(body.trim_end().ends_with("-->"));
9466 assert!(!body[..body.find('{').unwrap()].contains("-->"));
9467 }
9468
9469 #[test]
9470 fn an_unrelated_json_block_is_not_state() {
9471 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
9472 }
9473
9474 #[test]
9481 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
9482 let block = format!(
9483 "{STATE_MARKER}\n{}\n-->",
9484 serde_json::to_string(&state()).unwrap()
9485 );
9486 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
9487 assert!(
9488 is_state_comment(&block, "Spar-Bot", "spar-bot"),
9489 "a login differs only in case"
9490 );
9491
9492 assert!(
9493 !is_state_comment(
9494 "why is there a `<!-- spar:state` block on this PR?",
9495 "maintainer",
9496 "spar-bot"
9497 ),
9498 "a question about the block was treated as the block"
9499 );
9500 assert!(
9501 !is_state_comment(&block, "maintainer", "spar-bot"),
9502 "somebody else's comment is not spar's to rewrite"
9503 );
9504 assert!(
9505 !is_state_comment(
9506 &format!("here is what spar writes:\n\n{block}"),
9507 "spar-bot",
9508 "spar-bot"
9509 ),
9510 "the marker has to open the body, not appear in it"
9511 );
9512 assert!(
9513 !is_state_comment(
9514 &format!("{STATE_MARKER}\n{{not json\n-->"),
9515 "spar-bot",
9516 "spar-bot"
9517 ),
9518 "a block that does not parse is not state"
9519 );
9520 assert!(
9521 !is_state_comment(&block, "spar-bot", ""),
9522 "an unknown viewer proves nothing about who wrote it"
9523 );
9524 }
9525
9526 #[test]
9527 fn a_malformed_state_comment_is_none_not_a_panic() {
9528 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
9529 }
9530
9531 #[test]
9532 fn atomic_write_leaves_no_temp_file() {
9533 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
9534 let _ = std::fs::remove_dir_all(&dir);
9535 let path = dir.join("state").join("pr-7.json");
9536 write_json_atomic(&path, &state()).unwrap();
9537 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
9538 .unwrap()
9539 .flatten()
9540 .filter_map(|e| e.file_name().to_str().map(str::to_string))
9541 .collect();
9542 assert_eq!(vec!["pr-7.json".to_string()], files);
9543 let _ = std::fs::remove_dir_all(&dir);
9544 }
9545
9546 #[test]
9547 fn atomic_write_overwrites_rather_than_accumulating() {
9548 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
9549 let _ = std::fs::remove_dir_all(&dir);
9550 let path = dir.join("pr-7.json");
9551 for round in 1..4 {
9552 let mut s = state();
9553 s.round = round;
9554 write_json_atomic(&path, &s).unwrap();
9555 }
9556 let back: PersistedState =
9557 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9558 assert_eq!(3, back.round);
9559 let _ = std::fs::remove_dir_all(&dir);
9560 }
9561
9562 #[test]
9563 fn style_from_env_defaults_to_enforcing() {
9564 std::env::remove_var("SPAR_BAN_EM_DASH");
9565 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
9566 let style = style_from_env();
9567 assert!(style.ban_em_dash && style.ban_ai_attribution);
9568 assert!(
9569 !style.terse,
9570 "the commit filter must not truncate a commit message"
9571 );
9572 }
9573}
9574
9575#[cfg(test)]
9576mod comment_page_tests {
9577 use super::*;
9578
9579 #[test]
9580 fn a_single_merged_array_is_read() {
9581 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
9582 assert_eq!(2, pages.len());
9583 assert_eq!(Some(2), pages[1]["id"].as_i64());
9584 }
9585
9586 #[test]
9587 fn concatenated_pages_from_an_older_gh_are_read_too() {
9588 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
9589 assert_eq!(2, pages.len());
9590 }
9591
9592 #[test]
9596 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
9597 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
9598 let pages = parse_comment_pages(text);
9599 assert_eq!(2, pages.len(), "{pages:?}");
9600 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
9601 }
9602
9603 #[test]
9604 fn empty_output_is_no_comments_not_a_panic() {
9605 assert!(parse_comment_pages("").is_empty());
9606 assert!(parse_comment_pages(" ").is_empty());
9607 assert!(parse_comment_pages("[]").is_empty());
9608 }
9609
9610 #[test]
9611 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
9612 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
9613 }
9614
9615 #[test]
9616 fn a_write_postcheck_rejects_truncated_comment_pages() {
9617 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
9618 assert!(
9619 error.to_string().contains("unexpected comment pages"),
9620 "{error}"
9621 );
9622 }
9623
9624 #[test]
9625 fn a_write_postcheck_rejects_empty_or_non_array_output() {
9626 assert!(try_parse_comment_pages("").is_err());
9627 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
9628 assert!(try_parse_comment_pages("[]").is_ok());
9629 }
9630
9631 #[test]
9632 fn state_is_found_in_the_last_matching_comment() {
9633 let payload = |round: u32| {
9634 format!(
9635 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9636 )
9637 };
9638 let text = serde_json::to_string(&serde_json::json!([
9639 {"id": 1, "body": payload(1)},
9640 {"id": 2, "body": "looks good to me"},
9641 {"id": 3, "body": payload(5)},
9642 ]))
9643 .unwrap();
9644 let pages = parse_comment_pages(&text);
9645 let last = pages
9646 .iter()
9647 .rev()
9648 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
9649 .unwrap();
9650 assert_eq!(5, last.round);
9651 }
9652}
9653
9654#[cfg(test)]
9655mod linked_pr_tests {
9656 use super::*;
9657
9658 const REAL_PAYLOAD: &str = r#"[
9663 {"number":14252,"title":"fix: reject leading-dash branch names",
9664 "url":"https://github.com/cli/cli/pull/14252",
9665 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
9666 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9667 "url":"https://github.com/cli/cli/issues/14238"}]},
9668 {"number":14217,"title":"another change",
9669 "url":"https://github.com/cli/cli/pull/14217",
9670 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
9671 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9672 "url":"https://github.com/cli/cli/issues/9761"}]},
9673 {"number":14200,"title":"unlinked work",
9674 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
9675 ]"#;
9676
9677 #[test]
9678 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
9679 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
9680 assert_eq!(14252, pr.number);
9681 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
9682 }
9683
9684 #[test]
9685 fn the_right_pr_is_picked_out_of_several() {
9686 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
9687 }
9688
9689 #[test]
9690 fn an_issue_nobody_is_working_on_finds_nothing() {
9691 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
9692 }
9693
9694 #[test]
9695 fn an_unlinked_pr_is_never_matched() {
9696 for issue in [14200, 0, 1] {
9698 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
9699 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
9700 }
9701 }
9702 }
9703
9704 #[test]
9705 fn empty_or_broken_output_is_none_rather_than_a_panic() {
9706 assert!(find_linked_pr("", 1).is_none());
9707 assert!(find_linked_pr("[]", 1).is_none());
9708 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
9709 assert!(find_linked_pr("[{\"number\":", 1).is_none());
9710 }
9711
9712 #[test]
9719 fn an_unreadable_list_is_not_an_empty_list() {
9720 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
9721 assert!(linked_pr_rows("[{\"number\":").is_none());
9722 assert!(linked_pr_rows("").is_none());
9723
9724 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
9725 assert!(empty.is_empty());
9726 assert!(pick_linked_pr(&empty, 14238).is_none());
9727
9728 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
9729 assert_eq!(3, listed.len());
9730 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
9731 }
9732
9733 #[test]
9735 fn pr_view_reads_the_cross_repository_flag() {
9736 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
9737 "baseRefName":"main","state":"OPEN",
9738 "closingIssuesReferences":[],"isCrossRepository":true}"#;
9739 let pr: PrView = serde_json::from_str(json).unwrap();
9740 assert!(pr.is_cross_repository);
9741 assert!(pr.is_open());
9742
9743 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
9744 assert!(
9745 !serde_json::from_str::<PrView>(&same_repo)
9746 .unwrap()
9747 .is_cross_repository
9748 );
9749 }
9750}
9751
9752#[cfg(test)]
9753mod min_number_tests {
9754 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
9760 let mut numbers: Vec<i64> = open.to_vec();
9761 numbers.sort_unstable();
9762 if min_number > 0 {
9763 numbers.retain(|n| *n >= min_number);
9764 }
9765 numbers.truncate(limit);
9766 numbers
9767 }
9768
9769 #[test]
9770 fn the_floor_is_applied_before_the_cap_not_after() {
9771 let open = [12, 13, 14, 480, 481, 482];
9772 assert_eq!(vec![480, 481], pick(&open, 2, 480));
9773 assert!(!pick(&open, 2, 480).is_empty());
9776 }
9777
9778 #[test]
9779 fn no_floor_keeps_the_old_behaviour() {
9780 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
9781 }
9782
9783 #[test]
9784 fn the_floor_is_inclusive() {
9785 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
9786 }
9787
9788 #[test]
9789 fn a_floor_above_everything_open_yields_nothing() {
9790 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
9791 }
9792}