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
58const SPLIT_SLOTS: u32 = 20;
63
64#[derive(Debug, Clone)]
65pub struct SplitPushError {
66 message: String,
67 retain_worktree: bool,
68}
69
70impl SplitPushError {
71 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
72 Self {
73 message: message.into(),
74 retain_worktree,
75 }
76 }
77
78 pub fn retain_worktree(&self) -> bool {
79 self.retain_worktree
80 }
81}
82
83impl std::fmt::Display for SplitPushError {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 f.write_str(&self.message)
86 }
87}
88
89impl std::error::Error for SplitPushError {}
90
91fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
96 match attempt {
97 1 => format!("split-{parent}-{index}"),
98 n => format!("split-{parent}-{index}-{n}"),
99 }
100}
101
102#[derive(Debug)]
103pub struct Repo {
104 root: PathBuf,
105 pub style: Style,
106 pub branch_prefix: String,
107 pub state_store: StateStore,
108 pub followups: Followups,
109 pub drafts: Drafts,
110 viewer: OnceLock<String>,
116 checkpoints: Mutex<BTreeMap<i64, u64>>,
121 writes: WriteStats,
122}
123
124#[derive(Debug, Default)]
125struct WriteStats {
126 attempted: AtomicUsize,
127 failed: AtomicUsize,
128}
129
130#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
131pub(crate) struct WriteSummary {
132 pub(crate) attempted: usize,
133 pub(crate) failed: usize,
134}
135
136impl WriteSummary {
137 pub(crate) fn succeeded(self) -> usize {
138 self.attempted.saturating_sub(self.failed)
139 }
140}
141
142#[derive(Debug, Clone)]
148pub(crate) struct WorktreeBaseline {
149 attributes: AttributeState,
150 ignored_untracked: IgnoredState,
151 git_state: GitState,
152}
153
154#[derive(Debug, Clone)]
160pub(crate) struct WorktreeCheckpoint {
161 path: PathBuf,
162 attributes: AttributeState,
163 git_state: GitState,
164 ignored_untracked: IgnoredState,
165}
166
167#[derive(Debug, Clone, Default, PartialEq, Eq)]
168pub(crate) struct AttributeState {
169 files: BTreeMap<PathBuf, [u8; 32]>,
170}
171
172#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub(crate) struct IgnoredState {
178 files: BTreeMap<PathBuf, UntrackedFile>,
179 ignored: BTreeSet<PathBuf>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
189struct UntrackedFile {
190 kind: u8,
191 len: u64,
192 modified: Option<std::time::SystemTime>,
193 created: Option<std::time::SystemTime>,
194 readonly: bool,
195 symlink_target: Option<Vec<u8>>,
196 #[cfg(unix)]
197 device: u64,
198 #[cfg(unix)]
199 inode: u64,
200 #[cfg(unix)]
201 mode: u32,
202 #[cfg(unix)]
203 change_seconds: i64,
204 #[cfg(unix)]
205 change_nanoseconds: i64,
206}
207
208#[derive(Debug, Clone, Default, PartialEq, Eq)]
209pub(crate) struct GitState {
210 repositories: BTreeMap<PathBuf, RepositoryState>,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214struct RepositoryState {
215 head: String,
216 unsafe_index_flags: Vec<u8>,
217 tracked: BTreeMap<PathBuf, TrackedEntry>,
218 gitlinks: BTreeMap<PathBuf, String>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
222struct TrackedEntry {
223 index_mode: String,
224 index_oid: String,
225 worktree: Option<WorktreeFile>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229struct WorktreeFile {
230 mode: String,
231 #[cfg(unix)]
232 permissions: u32,
233 raw_oid: String,
234 fingerprint: [u8; 32],
235 content: [u8; 32],
236}
237
238struct Gitlink {
239 path: PathBuf,
240 oid: String,
241}
242
243struct IndexEntry {
244 path: PathBuf,
245 mode: String,
246 oid: String,
247}
248
249impl IgnoredState {
250 fn is_ignored(&self, path: &Path) -> bool {
251 self.ignored.contains(path)
252 }
253
254 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
255 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
256 paths.extend(after.files.keys().cloned());
257 paths
258 .into_iter()
259 .filter(|path| self.files.get(path) != after.files.get(path))
260 .collect()
261 }
262
263 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
264 self.files
265 .iter()
266 .filter(|(path, state)| after.files.get(*path) != Some(*state))
267 .map(|(path, _)| path.clone())
268 .collect()
269 }
270
271 fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
272 after
273 .files
274 .keys()
275 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
276 .cloned()
277 .collect()
278 }
279
280 pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
290 let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
291 paths.extend(after.files.keys());
292 paths.into_iter().any(|path| {
293 if self.files.get(path) == after.files.get(path)
294 && self.is_ignored(path) == after.is_ignored(path)
295 {
296 return false;
297 }
298 !(is_generated_artifact(path) && self.disposable(path) && after.disposable(path))
299 })
300 }
301
302 fn disposable(&self, path: &Path) -> bool {
305 !self.files.contains_key(path) || self.is_ignored(path)
306 }
307}
308
309#[derive(Default)]
316struct GeneratedArtifacts {
317 new_paths: BTreeSet<PathBuf>,
318 changed_paths: BTreeSet<PathBuf>,
319}
320
321impl GeneratedArtifacts {
322 fn left(&mut self, paths: Vec<PathBuf>) {
323 self.new_paths.extend(paths);
324 }
325
326 fn changed(&mut self, paths: Vec<PathBuf>) {
327 self.changed_paths.extend(paths);
328 }
329
330 fn report(&self, cwd: &Path) {
334 if !self.new_paths.is_empty() {
335 logdim!(
336 "the editing call left {} generated artifact(s) under a known build or cache \
337 directory in {}. They are not part of the commit.",
338 self.new_paths.len(),
339 cwd.display()
340 );
341 }
342 if !self.changed_paths.is_empty() {
343 logdim!(
344 "the editing call changed {} existing generated artifact(s) under a known build \
345 or cache directory in {}. They are not part of the commit.",
346 self.changed_paths.len(),
347 cwd.display()
348 );
349 }
350 }
351}
352
353fn is_generated_artifact(path: &Path) -> bool {
359 const DIRECTORIES: &[&str] = &[
360 "target",
361 "dist",
362 "node_modules",
363 "__pycache__",
364 ".pytest_cache",
365 ".mypy_cache",
366 ".ruff_cache",
367 ".tox",
368 ".nox",
369 ".venv",
370 "venv",
371 ".gradle",
372 ".build",
373 "DerivedData",
374 ".next",
375 ".nuxt",
376 ".svelte-kit",
377 ".turbo",
378 "coverage",
379 ];
380 path.components().any(|component| {
381 let std::path::Component::Normal(name) = component else {
382 return false;
383 };
384 DIRECTORIES
385 .iter()
386 .any(|directory| name == OsStr::new(directory))
387 })
388}
389
390fn merge_pr_args<'a>(
391 number: &'a str,
392 expected_head: Option<&'a str>,
393 delete_branch: bool,
394) -> Vec<&'a str> {
395 let mut args = vec!["pr", "merge", number, "--squash"];
396 if delete_branch {
397 args.push("--delete-branch");
398 }
399 if let Some(expected_head) = expected_head {
400 args.extend(["--match-head-commit", expected_head]);
401 }
402 args
403}
404
405fn reconcile_pr_creation(
406 branch: &str,
407 created: Result<String>,
408 found: Result<Option<PrRef>>,
409) -> Result<PrRef> {
410 match (created, found) {
411 (_, Ok(Some(pr))) => Ok(pr),
412 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
413 "PR creation reported success but none was found for {branch}"
414 ))),
415 (Err(create), Ok(None)) => Err(spar_err!(
416 "could not open a PR for {branch}. {}",
417 create.last_line()
418 )),
419 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
420 "PR creation reported success for {branch}, but it could not be verified. {}",
421 check.last_line()
422 ))),
423 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
424 "could not open a PR for {branch}. {} The result could not be verified: {}",
425 create.last_line(),
426 check.last_line()
427 ))),
428 }
429}
430
431fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
432 #[derive(Deserialize)]
433 #[serde(rename_all = "camelCase")]
434 struct Row {
435 number: i64,
436 #[serde(default)]
437 url: String,
438 #[serde(default)]
439 title: String,
440 base_ref_name: String,
441 }
442
443 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
444 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
445 })?;
446 Ok(rows
447 .into_iter()
448 .find(|row| row.base_ref_name == base)
449 .map(|row| PrRef {
450 number: row.number,
451 url: row.url,
452 title: row.title,
453 }))
454}
455
456fn signed(body: &str) -> String {
459 if body.contains(COMMENT_MARKER) {
460 return body.to_string();
461 }
462 format!("{body}\n\n{COMMENT_MARKER}")
463}
464
465pub fn is_spar_comment(body: &str) -> bool {
467 body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
468}
469
470fn has_exact_comment(comments: &[Value], body: &str) -> bool {
471 comments.iter().any(|comment| {
472 comment
473 .get("body")
474 .and_then(Value::as_str)
475 .is_some_and(|seen| seen == body)
476 })
477}
478
479fn reconcile_comment_post(
480 number: i64,
481 body: &str,
482 post_error: crate::error::SparError,
483 comments: Result<Vec<Value>>,
484) -> Result<()> {
485 match comments {
486 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
487 Ok(_) => Err(post_error),
488 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
489 "could not comment on #{number}. {} The result could not be verified: {}",
490 post_error.last_line(),
491 read_error.last_line()
492 ))),
493 }
494}
495
496fn reconcile_issue_edit(
497 number: i64,
498 wanted: &str,
499 edit_error: crate::error::SparError,
500 observed: Result<String>,
501) -> Result<()> {
502 match observed {
503 Ok(body) if body == wanted => Ok(()),
504 Ok(_) => Err(spar_err!(
505 "could not rewrite the body of #{number}. {}",
506 edit_error.last_line()
507 )),
508 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
509 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
510 edit_error.last_line(),
511 read_error.last_line()
512 ))),
513 }
514}
515
516fn issue_url_has_number(url: &str) -> bool {
517 url.trim()
518 .rsplit('/')
519 .next()
520 .and_then(|tail| tail.parse::<i64>().ok())
521 .is_some_and(|number| number > 0)
522}
523
524fn reconcile_issue_creation(
525 title: &str,
526 created: Result<String>,
527 found: Result<Option<ExistingIssue>>,
528) -> Result<String> {
529 match (created, found) {
530 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
531 (_, Ok(Some(issue))) => Ok(issue.url),
532 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
533 "issue creation reported success but no matching issue was found for {title:?}"
534 ))),
535 (Err(create), Ok(None)) => Err(spar_err!(
536 "could not file issue {title:?}. {}",
537 create.last_line()
538 )),
539 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
540 "issue creation reported success for {title:?}, but it could not be verified. {}",
541 check.last_line()
542 ))),
543 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
544 "could not file issue {title:?}. {} The result could not be verified: {}",
545 create.last_line(),
546 check.last_line()
547 ))),
548 }
549}
550
551fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
552 if output.trim().is_empty() {
553 return Ok(None);
554 }
555 for line in output.lines() {
556 let mut fields = line.split_whitespace();
557 let oid = fields.next().unwrap_or_default();
558 let name = fields.next().unwrap_or_default();
559 if name == remote_ref && !oid.is_empty() {
560 return Ok(Some(oid.to_string()));
561 }
562 }
563 Err(spar_err!(
564 "origin returned an unexpected ref listing for {remote_ref}"
565 ))
566}
567
568fn reconcile_failed_split_push(
569 branch: &str,
570 push_error: crate::error::SparError,
571 local: Result<String>,
572 remote: Result<String>,
573) -> std::result::Result<(), SplitPushError> {
574 let remote_ref = format!("refs/heads/{branch}");
575 match (local, remote) {
576 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
577 Ok(Some(oid)) if oid == local.trim() => Ok(()),
578 Ok(_) => Err(SplitPushError::new(
579 format!(
580 "could not create origin/{branch}. {} The remote branch is absent or points \
581 somewhere else. Nothing was overwritten.",
582 push_error.last_line()
583 ),
584 false,
585 )),
586 Err(check) => Err(SplitPushError::new(
587 format!(
588 "could not confirm whether origin/{branch} was created. {} The remote result \
589 could not be verified: {}",
590 push_error.last_line(),
591 check.last_line()
592 ),
593 true,
594 )),
595 },
596 (local, remote) => {
597 let check = match (local, remote) {
598 (Err(local), Err(remote)) => format!(
599 "the local commit could not be read: {}; origin could not be read: {}",
600 local.last_line(),
601 remote.last_line()
602 ),
603 (Err(local), _) => {
604 format!("the local commit could not be read: {}", local.last_line())
605 }
606 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
607 _ => unreachable!(),
608 };
609 Err(SplitPushError::new(
610 format!(
611 "could not confirm whether origin/{branch} was created. {} The result could \
612 not be verified because {check}",
613 push_error.last_line()
614 ),
615 true,
616 ))
617 }
618 }
619}
620
621impl Repo {
622 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
623 let root =
624 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
625 let inside = proc::run_str(
628 &["git", "rev-parse", "--is-inside-work-tree"],
629 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
630 )
631 .unwrap_or_default();
632 if inside.trim() != "true" {
633 bail!("not a git repository: {}", root.display());
634 }
635 let repo = Self {
636 root,
637 style: cfg.style.clone(),
638 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
639 state_store: cfg.loop_cfg.state_store,
640 followups: cfg.loop_cfg.followups,
641 drafts: cfg.loop_cfg.drafts,
642 viewer: OnceLock::new(),
643 checkpoints: Mutex::new(BTreeMap::new()),
644 writes: WriteStats::default(),
645 };
646 repo.self_exclude();
647 Ok(repo)
648 }
649
650 fn self_exclude(&self) {
658 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
659 let git_dir = git_dir.trim();
660 if git_dir.is_empty() {
661 return;
662 }
663 let path = Path::new(git_dir).join("info").join("exclude");
664 let existing = std::fs::read_to_string(&path).unwrap_or_default();
665
666 let wanted = [format!("/{WORKTREE_DIR}/"), format!("/{STATE_DIR}/")];
667 let missing: Vec<&String> = wanted
668 .iter()
669 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
670 .collect();
671 if missing.is_empty() {
672 return;
673 }
674
675 use std::io::Write;
676 if let Some(parent) = path.parent() {
677 let _ = std::fs::create_dir_all(parent);
678 }
679 let mut block = String::new();
680 if !existing.is_empty() && !existing.ends_with('\n') {
681 block.push('\n');
682 }
683 block.push_str("\n# added by spar: its worktrees and run state\n");
684 for line in missing {
685 block.push_str(line);
686 block.push('\n');
687 }
688 if let Ok(mut file) = std::fs::OpenOptions::new()
689 .create(true)
690 .append(true)
691 .open(&path)
692 {
693 let _ = file.write_all(block.as_bytes());
694 }
695 }
696
697 pub fn root(&self) -> &Path {
698 &self.root
699 }
700
701 pub(crate) fn write_summary(&self) -> WriteSummary {
702 WriteSummary {
703 attempted: self.writes.attempted.load(Ordering::Relaxed),
704 failed: self.writes.failed.load(Ordering::Relaxed),
705 }
706 }
707
708 pub(crate) fn record_write<T, E>(
709 &self,
710 result: std::result::Result<T, E>,
711 ) -> std::result::Result<T, E> {
712 self.record_write_outcome(result.is_err());
713 result
714 }
715
716 pub(crate) fn record_failed_write<T, E>(
717 &self,
718 result: std::result::Result<T, E>,
719 ) -> std::result::Result<T, E> {
720 if result.is_err() {
721 self.record_write_outcome(true);
722 }
723 result
724 }
725
726 fn record_write_outcome(&self, failed: bool) {
727 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
728 if failed {
729 self.writes.failed.fetch_add(1, Ordering::Relaxed);
730 }
731 }
732
733 pub fn clean(&self, text: &str) -> Result<String> {
739 let out = style::scrub(text, &self.style);
740 let bad = style::violations(&out, &self.style);
741 if !bad.is_empty() {
742 bail!(
743 "style gate could not clean text ({}): {}",
744 bad.join(", "),
745 style::clip(&out, 300)
746 );
747 }
748 Ok(out)
749 }
750
751 pub fn clean_body(&self, text: &str) -> Result<String> {
753 self.clean(&style::body(text, &self.style))
754 }
755
756 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
758 self.clean(&style::issue_body(text, &self.style))
759 }
760
761 pub fn clean_title(&self, text: &str) -> Result<String> {
772 Ok(style::title(&self.clean(text)?, &self.style))
773 }
774
775 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
776 let title = self.record_failed_write(self.clean_title(text))?;
777 if title.trim().is_empty() {
778 return self.record_failed_write(Err(spar_err!(
779 "nothing left of the title after cleaning it"
780 )));
781 }
782 Ok(title)
783 }
784
785 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
786 if self.followups == Followups::Issues {
787 self.clean_nonempty_title_for_write(text)
788 } else {
789 self.clean_title(text)
790 }
791 }
792
793 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
796 ExecOpts::new()
797 .cwd(cwd.unwrap_or(&self.root))
798 .check(check)
799 .timeout_secs(600)
800 }
801
802 pub fn git(&self, args: &[&str]) -> Result<String> {
803 self.git_at(None, args)
804 }
805
806 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
807 let argv = git_without_maintenance_argv(args);
808 proc::run(&argv, &self.git_opts(cwd, true))
809 }
810
811 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
817 let argv = git_without_automation_argv(args);
818 proc::run(
819 &argv,
820 &self.git_opts(Some(cwd), true).stop_descendants(true),
821 )
822 }
823
824 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
825 let argv = git_without_automation_argv(args);
826 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
827 .map(|output| output.ok())
828 }
829
830 pub fn git_try(&self, args: &[&str]) -> String {
832 self.git_try_at(None, args)
833 }
834
835 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
836 let argv = git_without_maintenance_argv(args);
837 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
838 }
839
840 pub fn default_branch(&self, configured: &str) -> String {
843 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
844 match refname.trim().rsplit('/').next() {
845 Some(name) if !name.is_empty() => name.to_string(),
846 _ => configured.to_string(),
847 }
848 }
849
850 pub fn branch_for_issue(&self, issue: i64) -> String {
858 format!("{}issue-{issue}", self.branch_prefix)
859 }
860
861 pub fn branch_for_pr(&self, number: i64) -> String {
862 format!("{}pr-{number}", self.branch_prefix)
863 }
864
865 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
874 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
875 }
876
877 fn ledger_path(&self) -> PathBuf {
878 self.root.join(STATE_DIR).join("branches.json")
879 }
880
881 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
882 std::fs::read_to_string(self.ledger_path())
883 .ok()
884 .and_then(|text| serde_json::from_str(&text).ok())
885 .unwrap_or_default()
886 }
887
888 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
889 let mut data = self.known_branches();
890 data.insert(
891 branch.to_string(),
892 BranchRecord {
893 kind: kind.to_string(),
894 number,
895 },
896 );
897 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
898 logdim!("could not record branch {branch}: {e}");
899 }
900 }
901
902 pub fn forget_branch(&self, branch: &str) {
903 let mut data = self.known_branches();
904 if data.remove(branch).is_none() {
905 return;
906 }
907 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
908 logdim!("could not update the branch record: {e}");
909 }
910 }
911
912 fn worktree_path(&self, name: &str) -> PathBuf {
915 self.root.join(WORKTREE_DIR).join(name)
916 }
917
918 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
920 let branch = self.branch_for_issue(issue);
921 let path = self.worktree_path(&format!("issue-{issue}"));
922
923 self.refuse_issue_branch_rebuild(issue, base)?;
924 self.refuse_dirty_worktree(&path, &format!("worktree for issue #{issue}"))?;
925
926 if !self.branch_deletion_is_safe(&branch)? {
927 bail!(
928 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
929 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
930 before retrying."
931 );
932 }
933
934 if !self.remove_worktree_at(&path)? {
935 bail!(
936 "the existing worktree for issue #{issue} could not be removed safely. Its \
937 branch was kept."
938 );
939 }
940 if !self.delete_branch_if_safe(&branch)? {
941 bail!(
942 "the existing branch {branch} changed or remained checked out while its \
943 worktree was being rebuilt. It was kept."
944 );
945 }
946
947 if let Some(parent) = path.parent() {
948 std::fs::create_dir_all(parent)
949 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
950 }
951
952 let path_str = path.display().to_string();
953 let remote_start = format!("origin/{base}");
954 let created = self
955 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
956 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
957
958 created.map_err(|e| {
961 spar_err!(
962 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
963 and does `origin` exist?",
964 e.last_line()
965 )
966 })?;
967 self.record_branch(&branch, "issue", issue);
968 Ok((path, branch))
969 }
970
971 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
978 let branch = self.branch_for_issue(issue);
979 let base_remote_ref = format!("refs/heads/{base}");
980 let base_tracking_ref = format!("refs/remotes/origin/{base}");
981 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
982 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
983 .map_err(|e| {
984 spar_err!(
985 "could not refresh origin/{base} before checking issue #{issue}: {}",
986 e.last_line()
987 )
988 })?;
989
990 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
991 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
992 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
993 bail!(
994 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
995 pull request accounts for them. Rebuilding it would force push over that \
996 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
997 it, or delete it with `git push origin --delete {branch}` if the remote \
998 branch is no longer needed."
999 );
1000 }
1001 }
1002
1003 let local_ref = format!("refs/heads/{branch}");
1004 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1005 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1006 let recorded_pr = self
1007 .known_branches()
1008 .get(&branch)
1009 .is_some_and(|record| record.kind == "pr");
1010 let preserved = ahead == 0
1011 || if recorded_pr {
1012 self.local_branch_is_preserved(&branch)?
1013 } else {
1014 self.pull_request_holds(&branch, &local_ref, base)
1015 };
1016 if !preserved {
1017 let listed = self
1018 .commit_lines(&self.root, &local_ref, base)
1019 .iter()
1020 .map(|line| format!(" {line}"))
1021 .collect::<Vec<_>>()
1022 .join("\n");
1023 bail!(
1024 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1025 no pull request preserves them. Rebuilding it would delete the only copy.\n\
1026 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1027 it, or delete it with `git branch -D {branch}` if it is stale."
1028 );
1029 }
1030 }
1031 Ok(())
1032 }
1033
1034 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1035 let live_ref = format!("refs/heads/{branch}");
1036 let tracking_ref = format!("refs/remotes/origin/{branch}");
1037 let listed = self
1038 .git(&["ls-remote", "--heads", "origin", &live_ref])
1039 .map_err(|e| {
1040 spar_err!(
1041 "could not verify whether origin/{branch} still exists: {}",
1042 e.last_line()
1043 )
1044 })?;
1045
1046 if remote_head_oid(&listed, &live_ref)?.is_some() {
1047 let refspec = format!("+{live_ref}:{tracking_ref}");
1048 self.git(&["fetch", "--no-tags", "origin", &refspec])
1049 .map_err(|e| {
1050 spar_err!(
1051 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1052 e.last_line()
1053 )
1054 })?;
1055 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1056 bail!("origin/{branch} was fetched but its tracking ref is missing");
1057 }
1058 return Ok(Some(tracking_ref));
1059 }
1060
1061 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1062 return Ok(None);
1063 }
1064 let expected = self
1065 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1066 .trim()
1067 .to_string();
1068 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1069 .map_err(|e| {
1070 spar_err!(
1071 "could not discard stale origin/{branch} tracking ref safely: {}",
1072 e.last_line()
1073 )
1074 })?;
1075 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1076 bail!(
1077 "origin/{branch} changed while its stale tracking ref was being removed. It was \
1078 kept."
1079 );
1080 }
1081 Ok(None)
1082 }
1083
1084 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1093 self.prs_for_branch(branch)
1094 .iter()
1095 .any(|pr| self.pr_head_holds(pr.number, refname, base))
1096 }
1097
1098 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1099 let head = format!("refs/spar/pr-head/{number}");
1100 let refspec = format!("+refs/pull/{number}/head:{head}");
1101 if self.git(&["fetch", "origin", &refspec]).is_err() {
1102 return false;
1103 }
1104 let held = self.commits_held_by(refname, base, &head);
1105 self.git_try(&["update-ref", "-d", &head]);
1106 held
1107 }
1108
1109 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1110 let argv = vec![
1111 "git".to_string(),
1112 "merge-base".to_string(),
1113 "--is-ancestor".to_string(),
1114 older.to_string(),
1115 newer.to_string(),
1116 ];
1117 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1118 match out.code {
1119 0 => Ok(true),
1120 1 => Ok(false),
1121 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1122 }
1123 }
1124
1125 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1126 let head = format!("refs/spar/pr-head/{number}");
1127 let refspec = format!("+refs/pull/{number}/head:{head}");
1128 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1129 spar_err!(
1130 "could not verify the immutable head of PR #{number}: {}",
1131 e.last_line()
1132 )
1133 })?;
1134 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1135 self.git_try(&["update-ref", "-d", &head]);
1136 held
1137 }
1138
1139 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1140 let text = self.gh(&[
1141 "pr",
1142 "list",
1143 "--head",
1144 branch,
1145 "--state",
1146 "all",
1147 "--json",
1148 "number,url,title",
1149 ])?;
1150 serde_json::from_str(text.trim())
1151 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1152 }
1153
1154 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1155 let branch_ref = format!("refs/heads/{branch}");
1156 if record.kind == "pr" {
1157 return self.pr_head_contains_checked(record.number, &branch_ref);
1158 }
1159 let prs = self.branch_prs_checked(branch)?;
1160 if prs.is_empty() {
1161 return Ok(false);
1162 }
1163 for pr in prs {
1164 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1165 return Ok(true);
1166 }
1167 }
1168 Ok(false)
1169 }
1170
1171 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1172 let local_ref = format!("refs/heads/{branch}");
1173 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1174 return Ok(true);
1175 }
1176 let oid = self
1177 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1178 .trim()
1179 .to_string();
1180 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
1181 if !durable_tip {
1182 let remote_ref = format!("refs/heads/{branch}");
1183 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
1184 durable_tip = remote.lines().any(|line| {
1185 line.split_whitespace()
1186 .next()
1187 .is_some_and(|remote_oid| remote_oid == oid)
1188 });
1189 }
1190 if !durable_tip {
1191 if let Some(record) = self.known_branches().get(branch) {
1192 durable_tip = self.branch_is_preserved_checked(branch, record)?;
1193 }
1194 }
1195 if !durable_tip {
1196 return Ok(false);
1197 }
1198 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1199 }
1200
1201 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
1205 let local_ref = format!("refs/heads/{branch}");
1206 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1207 return Ok(true);
1208 }
1209 let expected = self
1210 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1211 .trim()
1212 .to_string();
1213 if !self.branch_deletion_is_safe(branch)? {
1214 return Ok(false);
1215 }
1216 let checked_out = self
1217 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
1218 .lines()
1219 .any(|line| line == format!("branch {local_ref}"));
1220 if checked_out {
1221 return Ok(false);
1222 }
1223 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1224 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1225 }
1226
1227 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
1228 let local_ref = review_ref(number);
1229 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1230 return Ok(true);
1231 }
1232 let oid = self
1233 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1234 .trim()
1235 .to_string();
1236 if !self.pr_head_contains_checked(number, &local_ref)? {
1237 return Ok(false);
1238 }
1239 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1240 }
1241
1242 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
1243 let local_ref = review_ref(number);
1244 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1245 return Ok(true);
1246 }
1247 let expected = self
1248 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1249 .trim()
1250 .to_string();
1251 if !self.review_ref_deletion_is_safe(number)? {
1252 return Ok(false);
1253 }
1254 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1255 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1256 }
1257
1258 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
1262 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
1263 self.git_try(&["rev-list", "--count", &range, "--not", other])
1264 .trim()
1265 == "0"
1266 }
1267
1268 pub fn worktree_remove(&self, issue: i64) -> bool {
1269 let path = self.worktree_path(&format!("issue-{issue}"));
1270 match self.remove_worktree_at(&path) {
1271 Ok(removed) => removed,
1272 Err(error) => {
1273 logdim!(
1274 "kept {} because removal did not reach a confirmed quiet point: {}",
1275 path.display(),
1276 error.last_line()
1277 );
1278 false
1279 }
1280 }
1281 }
1282
1283 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
1288 let wanted = std::fs::canonicalize(path)
1289 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
1290 if wanted != path {
1295 return Ok(false);
1296 }
1297 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
1298 let raw = PathBuf::from(value.trim());
1299 let joined = if raw.is_absolute() {
1300 raw
1301 } else {
1302 cwd.join(raw)
1303 };
1304 std::fs::canonicalize(&joined)
1305 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
1306 };
1307 let expected =
1308 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
1309 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
1310 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
1311 let expected = resolve(&self.root, &expected)?;
1312 let actual = resolve(path, &actual)?;
1313 let top = resolve(path, &top)?;
1314 Ok(expected == actual && top == wanted)
1315 }
1316
1317 fn remove_worktree_at_with_force(&self, path: &Path, force: bool) -> Result<bool> {
1323 let existed = path.exists();
1324 if path.exists() {
1325 match self.worktree_belongs_to_repo(path) {
1326 Ok(true) => {}
1327 Ok(false) => {
1328 logdim!(
1329 "kept {} because it is not a worktree owned by this repository",
1330 path.display()
1331 );
1332 return Ok(false);
1333 }
1334 Err(e) => {
1335 logdim!(
1336 "kept {} because its worktree ownership could not be verified: {}",
1337 path.display(),
1338 e.last_line()
1339 );
1340 return Ok(false);
1341 }
1342 }
1343 if !force {
1344 match self.has_recoverable_work(path) {
1345 Ok(true) => {
1346 logdim!(
1347 "kept {} because it contains recoverable files or repository state",
1348 path.display()
1349 );
1350 return Ok(false);
1351 }
1352 Err(e) => {
1353 logdim!(
1354 "kept {} because its recoverable state could not be checked: {}",
1355 path.display(),
1356 e.last_line()
1357 );
1358 return Ok(false);
1359 }
1360 Ok(false) => {}
1361 }
1362 }
1363 }
1364 let path_str = path.display().to_string();
1365 let command_ok = if force {
1366 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
1367 } else {
1368 self.git_try_without_automation(&["worktree", "remove", &path_str])?
1369 };
1370 Ok((command_ok || !existed) && !path.exists())
1371 }
1372
1373 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
1374 self.remove_worktree_at_with_force(path, false)
1375 }
1376
1377 fn remove_worktree_at_force(&self, path: &Path) -> bool {
1379 match self.remove_worktree_at_with_force(path, true) {
1380 Ok(removed) => removed,
1381 Err(error) => {
1382 logdim!(
1383 "kept {} because removal did not reach a confirmed quiet point: {}",
1384 path.display(),
1385 error.last_line()
1386 );
1387 false
1388 }
1389 }
1390 }
1391
1392 fn remove_worktree_at_checked(&self, path: &Path) -> Result<bool> {
1397 if path.exists() && !self.worktree_belongs_to_repo(path)? {
1398 bail!(
1399 "{} is not a worktree owned by this repository, so it was kept",
1400 path.display()
1401 );
1402 }
1403 if path.exists() && self.has_recoverable_work(path)? {
1404 bail!(
1405 "the verified worktree at {} contains recoverable files or repository state. It \
1406 was kept.",
1407 path.display()
1408 );
1409 }
1410 let path_str = path.display().to_string();
1411 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
1412 .map_err(|e| {
1413 e.with_message(format!(
1414 "could not remove the verified worktree at {}: {}. It was kept.",
1415 path.display(),
1416 e.last_line()
1417 ))
1418 })?;
1419 Ok(!path.exists())
1420 }
1421
1422 fn refuse_dirty_worktree(&self, path: &Path, label: &str) -> Result<()> {
1423 if !path.is_dir() {
1424 return Ok(());
1425 }
1426 let has_files = std::fs::read_dir(path)
1427 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
1428 .next()
1429 .is_some();
1430 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
1431 spar_err!(
1432 "could not verify whether the existing {label} at {} belongs to this repository, \
1433 so it was kept: {}",
1434 path.display(),
1435 e.last_line()
1436 )
1437 })?;
1438 if !owned {
1439 if has_files {
1440 bail!(
1441 "the existing {label} at {} is not a worktree owned by \
1442 this repository. Refusing to remove it.",
1443 path.display()
1444 );
1445 }
1446 return Ok(());
1447 }
1448 if !path.join(".git").exists() {
1449 if has_files {
1450 bail!(
1451 "the existing {label} at {} is not a readable Git worktree and is not empty. \
1452 Refusing to remove it.",
1453 path.display()
1454 );
1455 }
1456 return Ok(());
1457 }
1458 let dirty = self.has_recoverable_work(path).map_err(|e| {
1459 spar_err!(
1460 "could not verify whether the existing {label} at {} is clean, so it was kept: \
1461 {}",
1462 path.display(),
1463 e.last_line()
1464 )
1465 })?;
1466 if dirty {
1467 bail!(
1468 "the existing {label} contains uncommitted changes or ignored files at {}. \
1469 Rebuilding it would delete those files.\nCommit or recover them before running this \
1470 command again, or use `spar clean --all` if they are not needed.",
1471 path.display()
1472 );
1473 }
1474 Ok(())
1475 }
1476
1477 pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
1479 let head = pr.head_ref_name.clone();
1480 if head.trim().is_empty() {
1481 bail!("PR #{} has no head branch to check out", pr.number);
1482 }
1483 let path = self.worktree_path(&format!("pr-{}", pr.number));
1484 let local = self.branch_for_pr(pr.number);
1485
1486 self.git(&["fetch", "origin", &head]).map_err(|e| {
1487 spar_err!(
1488 "could not fetch the branch behind PR #{}: {}",
1489 pr.number,
1490 e.last_line()
1491 )
1492 })?;
1493 let start = format!("origin/{head}");
1494 let start_ref = format!("refs/remotes/origin/{head}");
1495 let local_ref = format!("refs/heads/{local}");
1496 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1497 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
1498 if unpushed > 0 {
1499 bail!(
1500 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
1501 on {start}. Rebuilding it would delete their branch.\nInspect the worktree at \
1502 {} and push or recover those commits before running this command again.",
1503 pr.number,
1504 path.display()
1505 );
1506 }
1507 }
1508 self.refuse_dirty_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
1509 if !self.branch_deletion_is_safe(&local)? {
1510 bail!(
1511 "the existing branch {local} has a tip or reflog-only commit that no surviving \
1512 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1513 before retrying."
1514 );
1515 }
1516 if !self.remove_worktree_at(&path)? {
1517 bail!(
1518 "the existing worktree for PR #{} could not be removed safely. Its branch was \
1519 kept.",
1520 pr.number
1521 );
1522 }
1523 if !self.delete_branch_if_safe(&local)? {
1524 bail!(
1525 "the existing branch {local} changed or remained checked out while the PR \
1526 worktree was being rebuilt. It was kept."
1527 );
1528 }
1529
1530 let path_str = path.display().to_string();
1531 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
1532 self.record_branch(&local, "pr", pr.number);
1533 Ok((path, head))
1534 }
1535
1536 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
1546 let path = self.worktree_path(&format!("review-{number}"));
1547 let local_ref = review_ref(number);
1548 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
1549
1550 self.refuse_review_worktree_changes(number)?;
1551
1552 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1553 spar_err!(
1554 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
1555 every pull request, so this usually means the number is wrong or `origin` does \
1556 not point at the repository the PR is on.",
1557 e.last_line()
1558 )
1559 })?;
1560
1561 if let Some(parent) = path.parent() {
1562 std::fs::create_dir_all(parent)
1563 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1564 }
1565 if !self.remove_worktree_at(&path)? {
1566 bail!(
1567 "the existing review worktree for PR #{number} could not be removed safely. Its \
1568 reference was kept."
1569 );
1570 }
1571 let path_str = path.display().to_string();
1572 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
1573 Ok(path)
1574 }
1575
1576 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
1577 let path = self.worktree_path(&format!("review-{number}"));
1578 if !path.is_dir() {
1579 return Ok(());
1580 }
1581 let local_ref = review_ref(number);
1582 if !self.worktree_belongs_to_repo(&path)? {
1583 return Ok(());
1584 }
1585 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1586 bail!(
1587 "the existing review worktree for PR #{number} has no recorded head at \
1588 {local_ref}. Refusing to rebuild {}.",
1589 path.display()
1590 );
1591 }
1592 let worktree_head = self.head_oid_checked(&path)?;
1593 let recorded_head = self
1594 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1595 .trim()
1596 .to_string();
1597 if worktree_head != recorded_head {
1598 bail!(
1599 "the existing review worktree for PR #{number} has a local commit that is not on \
1600 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
1601 {} before retrying.",
1602 path.display()
1603 );
1604 }
1605 self.refuse_dirty_worktree(&path, &format!("review worktree for PR #{number}"))?;
1606 Ok(())
1607 }
1608
1609 pub fn worktree_for_split(
1622 &self,
1623 parent: i64,
1624 index: usize,
1625 start: &str,
1626 ) -> Result<(PathBuf, String)> {
1627 let slot = self.free_split_slot(parent, index)?;
1628 let branch = format!("{}{slot}", self.branch_prefix);
1629 let path = self.worktree_path(&slot);
1630
1631 if let Some(dir) = path.parent() {
1632 std::fs::create_dir_all(dir)
1633 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
1634 }
1635 self.refuse_dirty_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
1636 if !self.remove_worktree_at(&path)? {
1640 bail!(
1641 "the existing worktree for part {index} of PR #{parent} could not be removed \
1642 safely. No branch was created."
1643 );
1644 }
1645
1646 let path_str = path.display().to_string();
1647 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
1648 .map_err(|e| {
1649 spar_err!(
1650 "could not create a worktree for part {index} of #{parent}. {}",
1651 e.last_line()
1652 )
1653 })?;
1654 self.record_branch(&branch, "split", parent);
1657 Ok((path, branch))
1658 }
1659
1660 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
1667 for attempt in 1..=SPLIT_SLOTS {
1668 let slot = split_slot(parent, index, attempt);
1669 let branch = format!("{}{slot}", self.branch_prefix);
1670 self.git_try(&["fetch", "origin", &branch]);
1671 if !self.rev_exists(&self.root, &branch)
1672 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
1673 {
1674 return Ok(slot);
1675 }
1676 }
1677 bail!(
1678 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
1679 names are all taken. Inspect the existing branches and child pull requests. Finish \
1680 recording the earlier split, or remove every retained local worktree and branch, \
1681 child pull request, and remote split branch before starting over.",
1682 self.branch_for_split(parent, index)
1683 )
1684 }
1685
1686 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
1692 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
1693 Ok(!self
1694 .git(&["ls-remote", "--heads", "origin", &pattern])?
1695 .trim()
1696 .is_empty())
1697 }
1698
1699 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
1706 match self.branch_deletion_is_safe(branch) {
1707 Ok(true) => {}
1708 Ok(false) => {
1709 logdim!(
1710 "kept {branch} and {} because no surviving ref preserves its tip",
1711 dir.display()
1712 );
1713 return;
1714 }
1715 Err(error) => {
1716 logdim!(
1717 "kept {branch} and {} because preservation could not be verified: {}",
1718 dir.display(),
1719 error.last_line()
1720 );
1721 return;
1722 }
1723 }
1724 match self.remove_worktree_at(dir) {
1725 Ok(true) => match self.delete_branch_if_safe(branch) {
1726 Ok(true) => self.forget_branch(branch),
1727 Ok(false) => {
1728 logdim!("kept {branch} because its tip or reflog changed before deletion")
1729 }
1730 Err(error) => logdim!(
1731 "kept {branch} because deletion safety could not be rechecked: {}",
1732 error.last_line()
1733 ),
1734 },
1735 Ok(false) => {}
1736 Err(error) => logdim!(
1737 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
1738 dir.display(),
1739 error.last_line()
1740 ),
1741 }
1742 }
1743
1744 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
1750 let record = self.known_branches().get(branch).cloned();
1751 if record.is_none_or(|record| record.kind != "split") {
1752 logdim!("kept {branch} because no split branch record proves ownership");
1753 return false;
1754 }
1755 let local_ref = format!("refs/heads/{branch}");
1756 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
1757 Ok(value) => value.trim().to_string(),
1758 Err(error) => {
1759 logdim!(
1760 "kept {branch} because its tip could not be checked: {}",
1761 error.last_line()
1762 );
1763 return false;
1764 }
1765 };
1766 if expected != disposable_head {
1767 logdim!("kept {branch} because it moved beyond the disposable slice");
1768 return false;
1769 }
1770 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
1771 Ok(true) => {}
1772 Ok(false) => {
1773 logdim!(
1774 "kept {branch} because its reflog contains work outside the disposable slice"
1775 );
1776 return false;
1777 }
1778 Err(error) => {
1779 logdim!(
1780 "kept {branch} because its reflog could not be checked: {}",
1781 error.last_line()
1782 );
1783 return false;
1784 }
1785 }
1786 match self.head_oid_checked(dir) {
1787 Ok(head) if head == disposable_head => {}
1788 Ok(_) => {
1789 logdim!(
1790 "kept {branch} and {} because the worktree moved beyond the disposable slice",
1791 dir.display()
1792 );
1793 return false;
1794 }
1795 Err(error) => {
1796 logdim!(
1797 "kept {branch} and {} because its head could not be checked: {}",
1798 dir.display(),
1799 error.last_line()
1800 );
1801 return false;
1802 }
1803 }
1804 match self.remove_worktree_at_checked(dir) {
1805 Ok(true) => {}
1806 Ok(false) => return false,
1807 Err(error) => {
1808 logdim!(
1809 "kept {branch} and {} because the disposable slice could not be verified: {}",
1810 dir.display(),
1811 error.last_line()
1812 );
1813 return false;
1814 }
1815 }
1816 if let Err(error) =
1817 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
1818 {
1819 logdim!(
1820 "kept {branch} because its exact disposable tip could not be deleted: {}",
1821 error.last_line()
1822 );
1823 return false;
1824 }
1825 match self.exact_ref_exists_checked(&self.root, &local_ref) {
1826 Ok(false) => {
1827 self.forget_branch(branch);
1828 true
1829 }
1830 Ok(true) => {
1831 logdim!("kept {branch} because its ref still exists after deletion");
1832 false
1833 }
1834 Err(error) => {
1835 logdim!(
1836 "kept the branch record for {branch} because deletion could not be verified: {}",
1837 error.last_line()
1838 );
1839 false
1840 }
1841 }
1842 }
1843
1844 pub fn release_review_worktree(&self, number: i64) {
1845 let path = self.worktree_path(&format!("review-{number}"));
1846 match self.review_ref_deletion_is_safe(number) {
1847 Ok(true) => {}
1848 Ok(false) => {
1849 logdim!(
1850 "kept {} because no surviving ref preserves its review history",
1851 path.display()
1852 );
1853 return;
1854 }
1855 Err(error) => {
1856 logdim!(
1857 "kept {} because review history could not be verified: {}",
1858 path.display(),
1859 error.last_line()
1860 );
1861 return;
1862 }
1863 }
1864 match self.remove_worktree_at(&path) {
1865 Ok(true) => match self.delete_review_ref_if_safe(number) {
1866 Ok(true) => {}
1867 Ok(false) => logdim!(
1868 "kept {} because its review history changed before deletion",
1869 review_ref(number)
1870 ),
1871 Err(error) => logdim!(
1872 "kept {} because deletion safety could not be rechecked: {}",
1873 review_ref(number),
1874 error.last_line()
1875 ),
1876 },
1877 Ok(false) => {}
1878 Err(error) => logdim!(
1879 "kept {} because removal did not reach a confirmed quiet point: {}",
1880 path.display(),
1881 error.last_line()
1882 ),
1883 }
1884 }
1885
1886 pub(crate) fn release_review_worktree_checked(
1889 &self,
1890 number: i64,
1891 checkpoint: &WorktreeCheckpoint,
1892 ) -> Result<()> {
1893 let path = self.worktree_path(&format!("review-{number}"));
1894 self.require_unchanged_worktree(
1895 &path,
1896 checkpoint,
1897 &format!("review worktree for PR #{number}"),
1898 )?;
1899 if !self.review_ref_deletion_is_safe(number)? {
1900 bail!(
1901 "the review reference for PR #{number} has reflog-only recovery history. The \
1902 worktree and reference were kept."
1903 );
1904 }
1905 if !self.remove_worktree_at_checked(&path)? {
1906 bail!(
1907 "the verified review worktree at {} could not be removed, so its reference was \
1908 kept",
1909 path.display()
1910 );
1911 }
1912 if !self.delete_review_ref_if_safe(number)? {
1913 bail!(
1914 "the review reference for PR #{number} changed before deletion. The reference was \
1915 kept."
1916 );
1917 }
1918 Ok(())
1919 }
1920
1921 pub fn release_pr_worktree(&self, number: i64) -> bool {
1922 let path = self.worktree_path(&format!("pr-{number}"));
1923 let local = self.branch_for_pr(number);
1924 match self.branch_deletion_is_safe(&local) {
1925 Ok(true) => {}
1926 Ok(false) => {
1927 logdim!(
1928 "kept {local} and {} because no surviving ref preserves its tip",
1929 path.display()
1930 );
1931 return false;
1932 }
1933 Err(error) => {
1934 logdim!(
1935 "kept {local} and {} because preservation could not be verified: {}",
1936 path.display(),
1937 error.last_line()
1938 );
1939 return false;
1940 }
1941 }
1942 match self.remove_worktree_at(&path) {
1943 Ok(true) => match self.delete_branch_if_safe(&local) {
1944 Ok(true) => {
1945 self.forget_branch(&local);
1946 true
1947 }
1948 Ok(false) => {
1949 logdim!("kept {local} because its tip or reflog changed before deletion");
1950 false
1951 }
1952 Err(error) => {
1953 logdim!(
1954 "kept {local} because deletion safety could not be rechecked: {}",
1955 error.last_line()
1956 );
1957 false
1958 }
1959 },
1960 Ok(false) => false,
1961 Err(error) => {
1962 logdim!(
1963 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
1964 path.display(),
1965 error.last_line()
1966 );
1967 false
1968 }
1969 }
1970 }
1971
1972 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
1983 let remote = format!("origin/{base}");
1984 if self.rev_exists(cwd, &remote) {
1985 return remote;
1986 }
1987 if self.rev_exists(cwd, base) {
1988 logdim!("origin/{base} does not resolve, comparing against local {base}");
1989 return base.to_string();
1990 }
1991 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
1992 remote
1993 }
1994
1995 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
1996 let spec = format!("{refname}^{{commit}}");
1997 !self
1998 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
1999 .trim()
2000 .is_empty()
2001 }
2002
2003 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2004 let range = format!("{}..HEAD", self.base_ref(cwd, base));
2005 !self
2006 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2007 .trim()
2008 .is_empty()
2009 }
2010
2011 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2012 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2013 Ok(found.lines().any(|line| line.trim() == refname))
2014 }
2015
2016 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2017 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2018 count.trim().parse::<usize>().map_err(|e| {
2019 spar_err!(
2020 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2021 count.trim()
2022 )
2023 })
2024 }
2025
2026 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2027 let remote = format!("refs/remotes/origin/{base}");
2028 if self.exact_ref_exists_checked(cwd, &remote)? {
2029 return Ok(remote);
2030 }
2031 let local = format!("refs/heads/{base}");
2032 if self.exact_ref_exists_checked(cwd, &local)? {
2033 return Ok(local);
2034 }
2035 bail!("neither origin/{base} nor local branch {base} resolves")
2036 }
2037
2038 pub(crate) fn commit_count_checked(
2039 &self,
2040 cwd: &Path,
2041 refname: &str,
2042 base: &str,
2043 ) -> Result<usize> {
2044 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
2045 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
2046 count.trim().parse::<usize>().map_err(|e| {
2047 spar_err!(
2048 "git returned an invalid commit count for {range}: {:?} ({e})",
2049 count.trim()
2050 )
2051 })
2052 }
2053
2054 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
2055 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
2056 }
2057
2058 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
2059 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
2060 let head = head.trim().to_string();
2061 if head.is_empty() {
2062 bail!("git returned an empty HEAD for {}", cwd.display());
2063 }
2064 Ok(head)
2065 }
2066
2067 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
2072 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
2073 self.local_branch_is_preserved(branch.trim())
2074 }
2075
2076 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
2079 let known = self.known_branches();
2080 let Some(record) = known.get(branch) else {
2081 return Ok(false);
2082 };
2083 self.branch_is_preserved_checked(branch, record)
2084 }
2085
2086 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
2088 has_uncommitted_work(cwd)
2089 }
2090
2091 fn has_recoverable_work(&self, cwd: &Path) -> Result<bool> {
2094 repository_has_recoverable_work(cwd, true)
2095 }
2096
2097 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2099 let attributes = attribute_state(cwd)?;
2100 Ok(WorktreeBaseline {
2101 attributes,
2102 ignored_untracked: ignored_untracked_state(cwd)?,
2103 git_state: safe_git_state(cwd)?,
2104 })
2105 }
2106
2107 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2110 let attributes = attribute_state(cwd)?;
2111 Ok(WorktreeCheckpoint {
2112 path: std::fs::canonicalize(cwd)
2113 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2114 attributes,
2115 git_state: safe_git_state(cwd)?,
2116 ignored_untracked: ignored_untracked_state(cwd)?,
2117 })
2118 }
2119
2120 pub(crate) fn require_unchanged_worktree(
2123 &self,
2124 cwd: &Path,
2125 checkpoint: &WorktreeCheckpoint,
2126 label: &str,
2127 ) -> Result<()> {
2128 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2129 crate::error::SparError::uncertain_write(format!(
2130 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2131 cwd.display()
2132 ))
2133 })?;
2134 if resolved != checkpoint.path {
2135 return Err(uncertain_worktree_change(
2136 cwd,
2137 format!(
2138 "the {label} moved from {} to {} during inspection. It was kept.",
2139 checkpoint.path.display(),
2140 resolved.display()
2141 ),
2142 ));
2143 }
2144 let attributes = attribute_state(cwd).map_err(|e| {
2145 uncertain_worktree_change(
2146 cwd,
2147 format!(
2148 "could not verify attribute files in the {label} at {}: {}. It was kept.",
2149 cwd.display(),
2150 e.last_line()
2151 ),
2152 )
2153 })?;
2154 if attributes != checkpoint.attributes {
2155 return Err(uncertain_worktree_change(
2156 cwd,
2157 format!(
2158 "attribute files in the {label} at {} changed during inspection. It was \
2159 kept for recovery.",
2160 cwd.display()
2161 ),
2162 ));
2163 }
2164 let git_state = git_state(cwd).map_err(|e| {
2165 uncertain_worktree_change(
2166 cwd,
2167 format!(
2168 "could not verify the Git state of the {label} at {}: {}. It was kept.",
2169 cwd.display(),
2170 e.last_line()
2171 ),
2172 )
2173 })?;
2174 let ignored = ignored_untracked_state(cwd).map_err(|e| {
2175 uncertain_worktree_change(
2176 cwd,
2177 format!(
2178 "could not verify untracked files in the {label} at {}: {}. It was kept.",
2179 cwd.display(),
2180 e.last_line()
2181 ),
2182 )
2183 })?;
2184 if git_state != checkpoint.git_state
2185 || checkpoint
2186 .ignored_untracked
2187 .changed_beyond_generated(&ignored)
2188 {
2189 return Err(uncertain_worktree_change(
2190 cwd,
2191 format!(
2192 "the {label} at {} changed during a read-only inspection. It was kept for \
2193 recovery.",
2194 cwd.display()
2195 ),
2196 ));
2197 }
2198 Ok(())
2199 }
2200
2201 pub(crate) fn refuse_new_ignored_files(
2209 &self,
2210 cwd: &Path,
2211 baseline: &WorktreeBaseline,
2212 ) -> Result<()> {
2213 self.check_new_ignored_files(cwd, baseline).map(drop)
2214 }
2215
2216 fn allow_generated_ignored_files(
2219 &self,
2220 cwd: &Path,
2221 baseline: &WorktreeBaseline,
2222 ) -> Result<Vec<PathBuf>> {
2223 self.check_new_ignored_files(cwd, baseline)
2224 }
2225
2226 fn check_new_ignored_files(
2227 &self,
2228 cwd: &Path,
2229 baseline: &WorktreeBaseline,
2230 ) -> Result<Vec<PathBuf>> {
2231 self.refuse_changed_attributes(cwd, baseline)?;
2232 let after = ignored_untracked_state(cwd).map_err(|e| {
2233 uncertain_worktree_change(
2234 cwd,
2235 format!(
2236 "could not verify untracked files in {} after editing: {}. The worktree was \
2237 kept for recovery.",
2238 cwd.display(),
2239 e.last_line()
2240 ),
2241 )
2242 })?;
2243 let changed = baseline.ignored_untracked.changed_paths(&after);
2244 if changed.is_empty() {
2245 return Ok(Vec::new());
2246 }
2247 let (generated, changed): (Vec<_>, Vec<_>) = changed
2248 .into_iter()
2249 .partition(|path| after.is_ignored(path) && is_generated_artifact(path));
2250 if changed.is_empty() {
2251 return Ok(generated);
2252 }
2253 let mut listed = changed
2254 .iter()
2255 .take(5)
2256 .map(|path| format!("{:?}", path.as_os_str()))
2257 .collect::<Vec<_>>()
2258 .join(", ");
2259 if changed.len() > 5 {
2260 listed.push_str(&format!(", and {} more", changed.len() - 5));
2261 }
2262 Err(uncertain_worktree_change(
2263 cwd,
2264 format!(
2265 "the editing call created or changed untracked or ignored file(s) in {} that \
2266 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2267 recovery.",
2268 cwd.display()
2269 ),
2270 ))
2271 }
2272
2273 pub(crate) fn refuse_changed_existing_untracked(
2279 &self,
2280 cwd: &Path,
2281 baseline: &WorktreeBaseline,
2282 ) -> Result<()> {
2283 self.check_changed_existing_untracked(cwd, baseline)
2284 .map(drop)
2285 }
2286
2287 fn allow_changed_generated_artifacts(
2288 &self,
2289 cwd: &Path,
2290 baseline: &WorktreeBaseline,
2291 ) -> Result<Vec<PathBuf>> {
2292 self.check_changed_existing_untracked(cwd, baseline)
2293 }
2294
2295 fn check_changed_existing_untracked(
2296 &self,
2297 cwd: &Path,
2298 baseline: &WorktreeBaseline,
2299 ) -> Result<Vec<PathBuf>> {
2300 self.refuse_changed_attributes(cwd, baseline)?;
2301 let after = ignored_untracked_state(cwd).map_err(|e| {
2302 uncertain_worktree_change(
2303 cwd,
2304 format!(
2305 "could not verify existing untracked files in {} after editing: {}. The \
2306 worktree was kept for recovery.",
2307 cwd.display(),
2308 e.last_line()
2309 ),
2310 )
2311 })?;
2312 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2313 if changed.is_empty() {
2314 return Ok(Vec::new());
2315 }
2316 let (generated, changed): (Vec<_>, Vec<_>) = changed.into_iter().partition(|path| {
2317 baseline.ignored_untracked.is_ignored(path)
2318 && after.is_ignored(path)
2319 && is_generated_artifact(path)
2320 });
2321 if changed.is_empty() {
2322 return Ok(generated);
2323 }
2324 let mut listed = changed
2325 .iter()
2326 .take(5)
2327 .map(|path| format!("{:?}", path.as_os_str()))
2328 .collect::<Vec<_>>()
2329 .join(", ");
2330 if changed.len() > 5 {
2331 listed.push_str(&format!(", and {} more", changed.len() - 5));
2332 }
2333 Err(uncertain_worktree_change(
2334 cwd,
2335 format!(
2336 "the editing call changed or deleted existing untracked file(s) in {}: \
2337 {listed}. The worktree was kept for recovery.",
2338 cwd.display()
2339 ),
2340 ))
2341 }
2342
2343 pub(crate) fn refuse_unrepresented_tracked_changes(
2350 &self,
2351 cwd: &Path,
2352 baseline: &WorktreeBaseline,
2353 ) -> Result<()> {
2354 self.refuse_changed_attributes(cwd, baseline)?;
2355 let after = safe_git_state(cwd).map_err(|e| {
2356 uncertain_worktree_change(
2357 cwd,
2358 format!(
2359 "could not verify tracked files in {} after editing: {}. The worktree was \
2360 kept for recovery.",
2361 cwd.display(),
2362 e.last_line()
2363 ),
2364 )
2365 })?;
2366 let mut changed = Vec::new();
2367 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
2368 uncertain_worktree_change(
2369 cwd,
2370 format!(
2371 "could not record untracked files before verifying transformed content in {}: \
2372 {}. The worktree was kept for recovery.",
2373 cwd.display(),
2374 e.last_line()
2375 ),
2376 )
2377 })?;
2378 let mut filter_was_run = false;
2379 let mut filter_problem = None;
2380 let mut repositories: BTreeSet<PathBuf> =
2381 baseline.git_state.repositories.keys().cloned().collect();
2382 repositories.extend(after.repositories.keys().cloned());
2383 'repositories: for repository_path in repositories {
2384 let before_repository = baseline.git_state.repositories.get(&repository_path);
2385 let after_repository = after.repositories.get(&repository_path);
2386 if before_repository.is_none() || after_repository.is_none() {
2387 changed.push(repository_path.clone());
2388 continue;
2389 }
2390 if before_repository.map(|repository| &repository.gitlinks)
2391 != after_repository.map(|repository| &repository.gitlinks)
2392 {
2393 changed.push(repository_path.join("<gitlinks>"));
2394 }
2395 let mut paths = BTreeSet::new();
2396 if let Some(repository) = before_repository {
2397 paths.extend(repository.tracked.keys().cloned());
2398 }
2399 if let Some(repository) = after_repository {
2400 paths.extend(repository.tracked.keys().cloned());
2401 }
2402 for path in paths {
2403 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
2404 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
2405 let worktree_changed =
2406 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
2407 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
2408 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
2409 if !worktree_changed {
2410 continue;
2411 }
2412 if !index_changed {
2413 changed.push(repository_path.join(&path));
2414 continue;
2415 }
2416 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
2417 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
2418 let Some(current_entry) = current else {
2419 continue;
2420 };
2421 let Some(current_worktree) = current_worktree else {
2422 continue;
2423 };
2424 let content_changed =
2425 before_worktree.map(|file| file.content) != Some(current_worktree.content);
2426 let mode_changed = before_worktree.map(|file| file.mode.as_str())
2427 != Some(current_worktree.mode.as_str());
2428 let repository = cwd.join(&repository_path);
2429 let represented_content = if content_changed {
2430 filter_was_run = true;
2431 let result =
2432 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
2433 self.refuse_changed_attributes(cwd, baseline)?;
2434 match result {
2435 Ok(expected) => expected == current_worktree.content,
2436 Err(error) => {
2437 filter_problem = Some(format!(
2438 "could not verify transformed content for {:?}: {}",
2439 repository_path.join(&path),
2440 error.last_line()
2441 ));
2442 false
2443 }
2444 }
2445 } else {
2446 true
2447 };
2448 let represented_mode =
2449 !mode_changed || current_worktree.mode == current_entry.index_mode;
2450 if !represented_content || !represented_mode {
2451 changed.push(repository_path.join(&path));
2452 }
2453 if filter_problem.is_some() {
2454 break 'repositories;
2455 }
2456 }
2457 }
2458 if filter_was_run {
2459 self.refuse_changed_attributes(cwd, baseline)?;
2460 let verified = safe_git_state(cwd).map_err(|e| {
2461 uncertain_worktree_change(
2462 cwd,
2463 format!(
2464 "could not recheck tracked files after verifying transformed content in \
2465 {}: {}. The worktree was kept for recovery.",
2466 cwd.display(),
2467 e.last_line()
2468 ),
2469 )
2470 })?;
2471 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
2472 uncertain_worktree_change(
2473 cwd,
2474 format!(
2475 "could not recheck untracked files after verifying transformed content \
2476 in {}: {}. The worktree was kept for recovery.",
2477 cwd.display(),
2478 e.last_line()
2479 ),
2480 )
2481 })?;
2482 if verified != after
2483 || before_filter_untracked.changed_beyond_generated(&verified_untracked)
2484 {
2485 return Err(uncertain_worktree_change(
2486 cwd,
2487 "a content filter changed the worktree while SPAR verified the managed \
2488 commit. The worktree was kept for recovery.",
2489 ));
2490 }
2491 self.refuse_changed_existing_untracked(cwd, baseline)?;
2492 }
2493 if let Some(problem) = filter_problem {
2494 return Err(uncertain_worktree_change(
2495 cwd,
2496 format!("{problem}. The worktree was kept for recovery."),
2497 ));
2498 }
2499 if changed.is_empty() {
2500 return Ok(());
2501 }
2502 let mut listed = changed
2503 .iter()
2504 .take(5)
2505 .map(|path| format!("{:?}", path.as_os_str()))
2506 .collect::<Vec<_>>()
2507 .join(", ");
2508 if changed.len() > 5 {
2509 listed.push_str(&format!(", and {} more", changed.len() - 5));
2510 }
2511 Err(uncertain_worktree_change(
2512 cwd,
2513 format!(
2514 "the editing call changed tracked working-file bytes, modes, repositories, or \
2515 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
2516 recovery."
2517 ),
2518 ))
2519 }
2520
2521 pub(crate) fn refuse_changed_attributes(
2522 &self,
2523 cwd: &Path,
2524 baseline: &WorktreeBaseline,
2525 ) -> Result<()> {
2526 let after = attribute_state(cwd).map_err(|e| {
2527 uncertain_worktree_change(
2528 cwd,
2529 format!(
2530 "could not verify attribute files in {} after editing: {}. The worktree was \
2531 kept for recovery.",
2532 cwd.display(),
2533 e.last_line()
2534 ),
2535 )
2536 })?;
2537 if after == baseline.attributes {
2538 return Ok(());
2539 }
2540 Err(uncertain_worktree_change(
2541 cwd,
2542 format!(
2543 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
2544 refused to run a Git operation that could select a new external filter.",
2545 cwd.display()
2546 ),
2547 ))
2548 }
2549
2550 pub(crate) fn commit_pending_changes(
2555 &self,
2556 cwd: &Path,
2557 baseline: &WorktreeBaseline,
2558 preferred_subject: &str,
2559 fallback_subject: &str,
2560 ) -> Result<bool> {
2561 let mut artifacts = GeneratedArtifacts::default();
2562 self.refuse_changed_attributes(cwd, baseline)?;
2563 artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2564 refuse_unsafe_index_flags(cwd)?;
2565 if !self.has_uncommitted_changes(cwd)? {
2566 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2567 artifacts.report(cwd);
2568 return Ok(false);
2569 }
2570 self.stage_managed_changes(cwd, baseline).map_err(|e| {
2571 e.with_message(format!(
2572 "could not stage changes in {}: {}",
2573 cwd.display(),
2574 e.last_line()
2575 ))
2576 })?;
2577 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2580 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
2581 if !changed_gitlinks.is_empty() {
2582 let listed = changed_gitlinks
2583 .iter()
2584 .take(5)
2585 .map(|path| format!("{:?}", path.as_os_str()))
2586 .collect::<Vec<_>>()
2587 .join(", ");
2588 bail!(
2589 "the editing call added or changed a gitlink at {listed}. It was staged but not \
2590 committed because the referenced repository objects might exist only inside \
2591 this worktree. The worktree was kept for recovery."
2592 );
2593 }
2594 let mut subject = self.clean_title(preferred_subject)?;
2595 if subject.trim().is_empty() {
2596 subject = self.clean_title(fallback_subject)?;
2597 }
2598 self.commit_staged_changes(cwd, &subject).map_err(|e| {
2599 e.with_message(format!(
2600 "could not commit changes in {}: {}. The staged files were kept.",
2601 cwd.display(),
2602 e.last_line()
2603 ))
2604 })?;
2605 if has_tracked_or_staged_work(cwd)? {
2606 bail!(
2607 "the commit in {} left additional uncommitted files. They were kept for \
2608 recovery.",
2609 cwd.display()
2610 );
2611 }
2612 artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2613 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2614 artifacts.report(cwd);
2615 Ok(true)
2616 }
2617
2618 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
2619 let after = ignored_untracked_state(cwd)?;
2620 self.git_at_without_automation(cwd, &["add", "-u"])?;
2621 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
2622 if paths.is_empty() {
2623 return Ok(());
2624 }
2625 let mut input = Vec::new();
2626 for path in paths {
2627 input.extend(os_str_bytes(path.as_os_str())?);
2628 input.push(0);
2629 }
2630 let argv = git_without_automation_argv(&[
2631 "--literal-pathspecs",
2632 "add",
2633 "--pathspec-from-file=-",
2634 "--pathspec-file-nul",
2635 ]);
2636 proc::run_with_input_bytes(
2637 &argv,
2638 &self.git_opts(Some(cwd), true).stop_descendants(true),
2639 &input,
2640 )?;
2641 Ok(())
2642 }
2643
2644 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
2647 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
2648 .map(|_| ())
2649 }
2650
2651 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
2658 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2659 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
2660 .trim()
2661 .parse()
2662 .unwrap_or(0)
2663 }
2664
2665 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2669 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2670 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2671 .lines()
2672 .map(str::to_string)
2673 .collect()
2674 }
2675
2676 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
2691 let ancestor = self
2692 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
2693 .is_ok();
2694 if !ancestor {
2695 return None;
2696 }
2697 let range = format!("{earlier}..{later}");
2698 Some(
2699 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2700 .lines()
2701 .map(str::to_string)
2702 .collect(),
2703 )
2704 }
2705
2706 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2709 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2710 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
2711 .lines()
2712 .map(str::trim)
2713 .filter(|line| !line.is_empty())
2714 .map(str::to_string)
2715 .collect()
2716 }
2717
2718 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
2734 let range = format!("{}...HEAD", self.base_ref(cwd, base));
2735 self.git_try_at(
2736 Some(cwd),
2737 &["diff", "--name-only", "--no-renames", "-z", &range],
2738 )
2739 .split('\0')
2740 .filter(|path| !path.is_empty())
2741 .map(str::to_string)
2742 .collect()
2743 }
2744
2745 pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
2748 self.git_try_at(
2749 Some(cwd),
2750 &[
2751 "diff",
2752 "--name-only",
2753 "--no-renames",
2754 "-z",
2755 &format!("{from}..{to}"),
2756 ],
2757 )
2758 .split('\0')
2759 .filter(|path| !path.is_empty())
2760 .map(str::to_string)
2761 .collect()
2762 }
2763
2764 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
2767 let base_ref = self.base_ref(cwd, base);
2768 let out = self
2769 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
2770 .map_err(|e| {
2771 spar_err!(
2772 "could not find where {refname} and {base_ref} diverged. {}",
2773 e.last_line()
2774 )
2775 })?;
2776 let sha = out.trim().to_string();
2777 if sha.is_empty() {
2778 bail!("{refname} and {base_ref} share no history");
2779 }
2780 Ok(sha)
2781 }
2782
2783 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
2784 let range = format!("{}...HEAD", self.base_ref(cwd, base));
2785 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
2786 full.trim().to_string()
2787 }
2788
2789 pub fn rewrite_commits_if_needed(
2802 &self,
2803 cwd: &Path,
2804 base: &str,
2805 floor: Option<&str>,
2806 ) -> Result<()> {
2807 let base_ref = self.base_ref(cwd, base);
2808 let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
2809 self.report_commits_left_alone(cwd, &base_ref, floor);
2810
2811 let range = format!("{floor}..HEAD");
2812 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
2813
2814 let offenders = raw
2815 .split('\x1e')
2816 .filter_map(|entry| entry.split_once('\0'))
2817 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
2818 .count();
2819 if offenders == 0 {
2820 return Ok(());
2821 }
2822 logdim!("{offenders} commit message(s) violated style rules, rewriting");
2823
2824 let exe = self_binary()?;
2825 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
2826
2827 let argv: Vec<String> = [
2828 "git",
2829 "filter-branch",
2830 "-f",
2831 "--msg-filter",
2832 &filter,
2833 &range,
2834 ]
2835 .iter()
2836 .map(|s| s.to_string())
2837 .collect();
2838 let opts = ExecOpts::new()
2839 .cwd(cwd)
2840 .check(false)
2841 .timeout_secs(600)
2842 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
2843 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
2844 .env(
2845 "SPAR_BAN_AI_ATTRIBUTION",
2846 bool_env(self.style.ban_ai_attribution),
2847 );
2848 let _ = proc::run(&argv, &opts);
2849
2850 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
2851 if !style::violations(&after, &self.style).is_empty() {
2852 bail!(
2853 "commit messages still violate style rules after a rewrite in {}.",
2854 cwd.display()
2855 );
2856 }
2857 Ok(())
2858 }
2859
2860 fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
2865 if floor == base_ref {
2866 return;
2867 }
2868 let raw = self.git_try_at(
2869 Some(cwd),
2870 &[
2871 "log",
2872 &format!("{base_ref}..{floor}"),
2873 "--format=%H%x00%B%x1e",
2874 ],
2875 );
2876 let left: Vec<&str> = raw
2877 .split('\x1e')
2878 .filter_map(|entry| entry.split_once('\0'))
2879 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
2880 .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
2881 .collect();
2882 if left.is_empty() {
2883 return;
2884 }
2885 logdim!(
2886 "{} commit message(s) already on the branch break the style rules and were left \
2887 alone: {}. spar does not rewrite commits it did not write.",
2888 left.len(),
2889 left.join(", ")
2890 );
2891 }
2892
2893 pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
2908 let expected = expected.map(str::trim).filter(|head| !head.is_empty());
2909 if let Some(head) = expected {
2910 self.refuse_push_that_drops(cwd, head)?;
2911 }
2912 let refspec = format!("HEAD:{branch}");
2913 let lease = match expected {
2914 Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
2915 None => "--force-with-lease".to_string(),
2916 };
2917 let pushed = self
2918 .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
2919 .map(|_| ())
2920 .map_err(|e| {
2921 spar_err!(
2922 "could not push to origin/{branch}. {}\nCheck push access and whether the \
2923 branch moved under you.",
2924 e.last_line()
2925 )
2926 });
2927 self.record_write(pushed)
2928 }
2929
2930 fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
2938 let head = self.head_oid_checked(cwd)?;
2939 if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
2940 return Ok(());
2941 }
2942 bail!(
2943 "refusing to push {head} because it does not contain the reviewed head {expected}. \
2944 The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
2945 the intended commits on top of it before resuming.",
2946 cwd.display()
2947 )
2948 }
2949
2950 pub fn push_split_branch(
2959 &self,
2960 cwd: &Path,
2961 branch: &str,
2962 ) -> std::result::Result<(), SplitPushError> {
2963 let remote_ref = format!("refs/heads/{branch}");
2964 let lease = format!("--force-with-lease={remote_ref}:");
2965 let refspec = format!("HEAD:{remote_ref}");
2966 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
2967 Ok(_) => Ok(()),
2968 Err(push_error) => {
2969 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
2970 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
2971 reconcile_failed_split_push(branch, push_error, local, remote)
2972 }
2973 };
2974 self.record_write(result)
2975 }
2976
2977 pub fn gh(&self, args: &[&str]) -> Result<String> {
2980 self.gh_at(None, args)
2981 }
2982
2983 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
2984 let mut argv = vec!["gh".to_string()];
2985 argv.extend(args.iter().map(|s| s.to_string()));
2986 proc::run(
2987 &argv,
2988 &ExecOpts::new()
2989 .cwd(cwd.unwrap_or(&self.root))
2990 .timeout_secs(300),
2991 )
2992 }
2993
2994 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3000 let mut argv = vec!["gh".to_string()];
3001 argv.extend(args.iter().map(|s| s.to_string()));
3002 proc::run(
3003 &argv,
3004 &ExecOpts::new()
3005 .cwd(&self.root)
3006 .timeout_secs(300)
3007 .stdin(stdin),
3008 )
3009 }
3010
3011 pub fn gh_try(&self, args: &[&str]) -> String {
3012 let mut argv = vec!["gh".to_string()];
3013 argv.extend(args.iter().map(|s| s.to_string()));
3014 proc::run(
3015 &argv,
3016 &ExecOpts::new()
3017 .cwd(&self.root)
3018 .check(false)
3019 .timeout_secs(300),
3020 )
3021 .unwrap_or_default()
3022 }
3023
3024 pub fn viewer_login(&self) -> Result<&str> {
3035 if let Some(login) = self.viewer.get() {
3036 return Ok(login);
3037 }
3038 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3039 let login = if !rest.trim().is_empty() {
3040 rest.trim().to_string()
3041 } else {
3042 self.gh(&[
3045 "api",
3046 "graphql",
3047 "-f",
3048 "query={ viewer { login } }",
3049 "--jq",
3050 ".data.viewer.login",
3051 ])
3052 .map_err(|e| {
3053 spar_err!(
3054 "could not find out who `gh` is authenticated as, so spar cannot tell its \
3055 own comments from anybody else's. {}\nRun `gh auth status`.",
3056 e.last_line()
3057 )
3058 })?
3059 .trim()
3060 .to_string()
3061 };
3062 if login.is_empty() {
3063 bail!("`gh` reported an empty login. Run `gh auth status`.");
3064 }
3065 Ok(self.viewer.get_or_init(|| login))
3066 }
3067
3068 pub fn read_issue(&self, number: i64) -> Result<Issue> {
3074 let text = self
3075 .gh(&[
3076 "issue",
3077 "view",
3078 &number.to_string(),
3079 "--json",
3080 "number,title,body,labels,state,stateReason,url",
3081 ])
3082 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3083 serde_json::from_str(&text)
3084 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3085 }
3086
3087 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3088 let mut issues = Vec::new();
3089 for number in numbers {
3090 let text = self
3091 .gh(&[
3092 "issue",
3093 "view",
3094 &number.to_string(),
3095 "--json",
3096 "number,title,body,labels,state,stateReason,url",
3097 ])
3098 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3099 let issue: Issue = serde_json::from_str(&text)
3100 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3101 if issue.is_closed() {
3102 crate::log!("issue #{number} is closed, skipping");
3103 continue;
3104 }
3105 issues.push(issue);
3106 }
3107 if issues.is_empty() {
3108 bail!("no open issues to work on");
3109 }
3110 Ok(issues)
3111 }
3112
3113 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3119 #[derive(Deserialize)]
3120 struct Row {
3121 number: i64,
3122 }
3123 let text = self.gh(&[
3124 kind,
3125 "list",
3126 "--state",
3127 "open",
3128 "--limit",
3129 &FETCH_CEILING.to_string(),
3130 "--json",
3131 "number",
3132 ])?;
3133 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3134 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3135 numbers.sort_unstable();
3136
3137 let noun = if kind == "issue" { "issues" } else { "PRs" };
3138 let found = numbers.len();
3139 if min_number > 0 {
3140 numbers.retain(|n| *n >= min_number);
3141 let skipped = found - numbers.len();
3142 if skipped > 0 {
3143 crate::log!("{skipped} open {noun} below #{min_number} skipped");
3144 }
3145 }
3146 if found >= FETCH_CEILING {
3147 crate::log!(
3148 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3149 considered."
3150 );
3151 }
3152 if numbers.len() > limit {
3153 crate::log!(
3154 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3155 explicitly.",
3156 numbers.len()
3157 );
3158 numbers.truncate(limit);
3159 }
3160 Ok(numbers)
3161 }
3162
3163 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3165 self.open_numbers("issue", limit, min_number)
3166 }
3167
3168 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3169 self.open_numbers("pr", limit, min_number)
3170 }
3171
3172 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3173 self.branch_prs(branch, "open").into_iter().next()
3174 }
3175
3176 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3179 let text = self.gh(&[
3180 "pr",
3181 "list",
3182 "--head",
3183 branch,
3184 "--base",
3185 base,
3186 "--state",
3187 "open",
3188 "--json",
3189 "number,url,title,baseRefName",
3190 ])?;
3191 pr_for_base(&text, branch, base)
3192 }
3193
3194 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3198 self.branch_prs(branch, "all")
3199 }
3200
3201 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3202 let text = self.gh_try(&[
3203 "pr",
3204 "list",
3205 "--head",
3206 branch,
3207 "--state",
3208 state,
3209 "--json",
3210 "number,url,title",
3211 ]);
3212 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3213 }
3214
3215 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3221 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3222 let text = self
3223 .gh(&[
3224 "api",
3225 &path,
3226 "--jq",
3227 "if .pull_request then \"pr\" else \"issue\" end",
3228 ])
3229 .map_err(|e| {
3230 spar_err!(
3231 "no issue or pull request #{number} in this repository. {}",
3232 e.last_line()
3233 )
3234 })?;
3235 match text.trim() {
3236 "pr" => Ok(ItemKind::Pr),
3237 "issue" => Ok(ItemKind::Issue),
3238 other => Err(spar_err!(
3239 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3240 )),
3241 }
3242 }
3243
3244 pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
3255 let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
3256 if let Some(pr) = mine {
3257 return Ok(Some(pr));
3258 }
3259 let listed = match self.gh(&[
3260 "pr",
3261 "list",
3262 "--state",
3263 "open",
3264 "--limit",
3265 &FETCH_CEILING.to_string(),
3266 "--json",
3267 "number,url,title,baseRefName,closingIssuesReferences",
3268 ]) {
3269 Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
3270 spar_err!("could not read the pull request list while looking for #{issue}")
3271 })?,
3272 Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
3273 };
3274 if listed.len() >= FETCH_CEILING {
3275 logwarn!(
3276 "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a pull request past it cannot be seen from here."
3277 );
3278 }
3279 let default = self.default_branch(base);
3280 if !base.eq_ignore_ascii_case(&default) {
3281 logwarn!(
3285 "#{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."
3286 );
3287 }
3288 Ok(pick_linked_pr(&listed, issue))
3289 }
3290
3291 fn pr_for_branch_failing_closed(
3293 &self,
3294 branch: &str,
3295 base: &str,
3296 issue: i64,
3297 ) -> Result<Option<PrRef>> {
3298 match self.try_pr_for_branch(branch, base) {
3299 Ok(found) => Ok(found),
3300 Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
3301 }
3302 }
3303
3304 fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
3313 if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
3314 return Err(spar_err!(
3315 "could not check whether a pull request already exists for #{issue}: {}. \
3316 Refusing to implement over the top of work that may already be open.",
3317 e.last_line()
3318 ));
3319 }
3320 logwarn!(
3321 "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
3322 whether a pull request already exists for it.",
3323 e.last_line()
3324 );
3325 Ok(())
3326 }
3327
3328 pub fn pr_view(&self, number: i64) -> Result<PrView> {
3329 let text = self.gh(&[
3330 "pr",
3331 "view",
3332 &number.to_string(),
3333 "--json",
3334 "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3335 ])?;
3336 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3337 }
3338
3339 fn try_pr_state(&self, number: i64) -> Result<String> {
3340 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3341 serde_json::from_str::<Value>(text.trim())
3342 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3343 .get("state")
3344 .and_then(Value::as_str)
3345 .map(str::to_string)
3346 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3347 }
3348
3349 pub fn pr_state(&self, number: i64) -> String {
3350 self.try_pr_state(number).unwrap_or_default()
3351 }
3352
3353 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3355 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3356 let oid = serde_json::from_str::<Value>(&text)
3357 .ok()
3358 .and_then(|value| {
3359 value
3360 .get("headRefOid")
3361 .and_then(Value::as_str)
3362 .map(str::trim)
3363 .filter(|oid| !oid.is_empty())
3364 .map(str::to_string)
3365 })
3366 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3367 Ok(oid)
3368 }
3369
3370 pub fn create_pr(
3371 &self,
3372 cwd: &Path,
3373 branch: &str,
3374 base: &str,
3375 title: &str,
3376 body: &str,
3377 ) -> Result<PrRef> {
3378 let title = self.record_failed_write(self.clean_title(title))?;
3379 let body = self.record_failed_write(self.clean(body))?;
3380 let mut argv = vec![
3381 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3382 ];
3383 if self.drafts != Drafts::Never {
3384 argv.push("--draft");
3385 }
3386 let created = self.gh_at(Some(cwd), &argv);
3387 let found = self.try_pr_for_branch(branch, base);
3388 self.record_write(reconcile_pr_creation(branch, created, found))
3389 }
3390
3391 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3392 let body = signed(&self.record_failed_write(self.clean(body))?);
3393 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3394 if has_exact_comment(&comments, &body) {
3395 return Ok(());
3396 }
3397 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3398 let result = match posted {
3399 Ok(_) => Ok(()),
3400 Err(post_error) => {
3401 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3402 }
3403 };
3404 self.record_write(result)
3405 }
3406
3407 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3408 let body = signed(&self.record_failed_write(self.clean(body))?);
3409 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3410 if has_exact_comment(&comments, &body) {
3411 return Ok(());
3412 }
3413 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3414 let result = match posted {
3415 Ok(_) => Ok(()),
3416 Err(post_error) => {
3417 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3418 }
3419 };
3420 self.record_write(result)
3421 }
3422
3423 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3428 self.comment_issue(number, body)?;
3429 let n = number.to_string();
3430 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3431 Ok(_) => Ok(()),
3432 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3434 spar_err!(
3435 "commented on #{number} but could not close it: {}",
3436 e.last_line()
3437 )
3438 }),
3439 };
3440 self.record_write(closed)
3441 }
3442
3443 pub fn edit_issue_body(
3458 &self,
3459 number: i64,
3460 expected: &str,
3461 body: &str,
3462 inserted: &str,
3463 ) -> Result<()> {
3464 let cleaned = self.record_failed_write(self.clean(inserted))?;
3465 if cleaned.trim() != inserted.trim() {
3466 return self.record_failed_write(Err(spar_err!(
3467 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
3468 )));
3469 }
3470 let current = self.record_failed_write(self.issue_body(number))?;
3471 if current != expected {
3472 return self.record_failed_write(Err(spar_err!(
3473 "the body of #{number} changed since it was read, so it was left alone rather \
3474 than written over."
3475 )));
3476 }
3477 let edited = self.gh_stdin(
3478 &["issue", "edit", &number.to_string(), "--body-file", "-"],
3479 body,
3480 );
3481 let result = match edited {
3482 Ok(_) => Ok(()),
3483 Err(edit_error) => {
3484 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
3485 }
3486 };
3487 self.record_write(result)
3488 }
3489
3490 pub fn issue_body(&self, number: i64) -> Result<String> {
3492 #[derive(Deserialize)]
3493 struct Row {
3494 #[serde(default)]
3495 body: Option<String>,
3496 }
3497 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
3498 let row: Row = serde_json::from_str(text.trim())
3499 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3500 Ok(row.body.unwrap_or_default())
3501 }
3502
3503 pub fn open_issue_rows(&self) -> Vec<Issue> {
3508 let text = self.gh_try(&[
3509 "issue",
3510 "list",
3511 "--state",
3512 "open",
3513 "--limit",
3514 &FETCH_CEILING.to_string(),
3515 "--json",
3516 "number,title,body,labels,state,stateReason,url",
3517 ]);
3518 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
3519 }
3520
3521 pub fn open_pr_rows(&self) -> Vec<PrRow> {
3523 let text = self.gh_try(&[
3524 "pr",
3525 "list",
3526 "--state",
3527 "open",
3528 "--limit",
3529 &FETCH_CEILING.to_string(),
3530 "--json",
3531 "number,title,changedFiles,additions,deletions",
3532 ]);
3533 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
3534 }
3535
3536 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
3537 self.create_issue_apart_from(title, body, None)
3538 }
3539
3540 pub fn create_issue_apart_from(
3541 &self,
3542 title: &str,
3543 body: &str,
3544 apart_from: Option<i64>,
3545 ) -> Result<String> {
3546 let title = self.record_failed_write(self.clean_title(title))?;
3547 let body = self.record_failed_write(self.clean_issue_body(body))?;
3548 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
3549 let result = match created {
3550 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
3551 created => {
3552 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
3553 reconcile_issue_creation(&title, created, found)
3554 }
3555 };
3556 self.record_write(result)
3557 }
3558}
3559
3560#[derive(Debug, Clone)]
3562pub struct ExistingIssue {
3563 pub number: i64,
3564 pub url: String,
3565 pub title: String,
3566 pub body: String,
3567 pub open: bool,
3568}
3569
3570impl Repo {
3571 pub(crate) fn try_exact_issue_apart_from(
3572 &self,
3573 title: &str,
3574 body: &str,
3575 apart_from: Option<i64>,
3576 ) -> Result<Option<ExistingIssue>> {
3577 #[derive(Deserialize)]
3578 #[serde(rename_all = "camelCase")]
3579 struct Row {
3580 number: i64,
3581 #[serde(default)]
3582 title: String,
3583 #[serde(default)]
3584 url: String,
3585 #[serde(default)]
3586 body: Option<String>,
3587 #[serde(default)]
3588 state: String,
3589 }
3590
3591 let text = self.gh(&[
3592 "issue",
3593 "list",
3594 "--state",
3595 "all",
3596 "--limit",
3597 "100",
3598 "--json",
3599 "number,title,url,body,state,stateReason",
3600 ])?;
3601 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3602 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
3603 Ok(rows
3604 .into_iter()
3605 .filter(|row| Some(row.number) != apart_from)
3606 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
3607 .map(|row| ExistingIssue {
3608 number: row.number,
3609 url: row.url,
3610 title: row.title,
3611 body: row.body.unwrap_or_default(),
3612 open: row.state.eq_ignore_ascii_case("open"),
3613 }))
3614 }
3615
3616 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
3624 self.find_similar_issue_apart_from(title, body, None)
3625 }
3626
3627 pub fn find_similar_issue_apart_from(
3635 &self,
3636 title: &str,
3637 body: &str,
3638 apart_from: Option<i64>,
3639 ) -> Option<ExistingIssue> {
3640 self.try_similar_issue(title, body, apart_from, true)
3641 .ok()
3642 .flatten()
3643 }
3644
3645 pub fn try_find_similar_issue_apart_from(
3647 &self,
3648 title: &str,
3649 body: &str,
3650 apart_from: Option<i64>,
3651 ) -> Result<Option<ExistingIssue>> {
3652 self.try_similar_issue(title, body, apart_from, false)
3653 }
3654
3655 fn try_similar_issue(
3657 &self,
3658 title: &str,
3659 body: &str,
3660 apart_from: Option<i64>,
3661 include_closed: bool,
3662 ) -> Result<Option<ExistingIssue>> {
3663 #[derive(Deserialize)]
3664 #[serde(rename_all = "camelCase")]
3665 struct Row {
3666 number: i64,
3667 #[serde(default)]
3668 title: String,
3669 #[serde(default)]
3670 url: String,
3671 #[serde(default)]
3672 body: String,
3673 #[serde(default)]
3674 state: String,
3675 }
3676 if title.trim().is_empty() {
3677 return Ok(None);
3678 }
3679 let query: String = title
3682 .chars()
3683 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3684 .take(120)
3685 .collect();
3686 let state = if include_closed { "all" } else { "open" };
3695 let text = self.gh(&[
3696 "issue",
3697 "list",
3698 "--state",
3699 state,
3700 "--limit",
3701 "100",
3702 "--search",
3703 query.trim(),
3704 "--json",
3705 "number,title,url,body,state,stateReason",
3706 ])?;
3707 let rows: Vec<Row> = serde_json::from_str(text.trim())
3708 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
3709
3710 Ok(rows
3711 .into_iter()
3712 .filter(|row| Some(row.number) != apart_from)
3713 .find(|row| {
3714 row.title.trim().eq_ignore_ascii_case(title.trim())
3715 || textsim::covers(title, body, &row.title, &row.body)
3716 })
3717 .map(|row| ExistingIssue {
3718 number: row.number,
3719 url: row.url,
3720 title: row.title,
3721 open: row.state.eq_ignore_ascii_case("open"),
3722 body: row.body,
3723 }))
3724 }
3725
3726 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
3728 #[derive(Deserialize)]
3729 struct Row {
3730 title: String,
3731 url: String,
3732 }
3733 let needle = title.trim().to_lowercase();
3734 if needle.is_empty() {
3735 return None;
3736 }
3737 let query: String = title
3739 .chars()
3740 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3741 .take(120)
3742 .collect();
3743 let text = self.gh_try(&[
3744 "issue",
3745 "list",
3746 "--state",
3747 "all",
3748 "--limit",
3749 "100",
3750 "--search",
3751 query.trim(),
3752 "--json",
3753 "number,title,url",
3754 ]);
3755 serde_json::from_str::<Vec<Row>>(text.trim())
3756 .ok()?
3757 .into_iter()
3758 .find(|row| row.title.trim().to_lowercase() == needle)
3759 .map(|row| row.url)
3760 }
3761
3762 pub fn mark_ready(&self, number: i64) -> bool {
3770 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
3771 Ok(_) => true,
3772 Err(e) => {
3773 logdim!(
3774 "PR #{number} is approved but could not be taken out of draft: {}",
3775 e.last_line()
3776 );
3777 false
3778 }
3779 }
3780 }
3781
3782 pub fn merge_pr(&self, number: i64) -> Result<()> {
3786 let n = number.to_string();
3787 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
3788 Ok(_) => Ok(()),
3789 Err(e) => {
3790 if self.pr_state(number) == "MERGED" {
3791 logdim!(
3792 "PR #{number} merged; branch cleanup did not finish: {}",
3793 e.last_line()
3794 );
3795 Ok(())
3796 } else {
3797 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3798 }
3799 }
3800 };
3801 self.record_write(merged)
3802 }
3803
3804 pub fn merge_pr_at_head(
3806 &self,
3807 number: i64,
3808 expected_head: &str,
3809 delete_branch: bool,
3810 ) -> Result<()> {
3811 let n = number.to_string();
3812 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
3813 Ok(_) => Ok(()),
3814 Err(e) => {
3815 if self.pr_state(number) == "MERGED" {
3816 logdim!(
3817 "PR #{number} merged; branch cleanup did not finish: {}",
3818 e.last_line()
3819 );
3820 Ok(())
3821 } else {
3822 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3823 }
3824 }
3825 };
3826 self.record_write(merged)
3827 }
3828
3829 pub fn followups_path(&self) -> PathBuf {
3834 self.root.join(STATE_DIR).join("followups.md")
3835 }
3836
3837 pub fn worked_followups_path(&self) -> PathBuf {
3845 self.root.join(STATE_DIR).join("followups.done.md")
3846 }
3847
3848 pub fn spend_path(&self) -> PathBuf {
3851 self.root.join(STATE_DIR).join("state").join("spend.json")
3852 }
3853
3854 pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
3860 if calls.is_empty() {
3861 return Ok(());
3862 }
3863 let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
3864 .ok()
3865 .and_then(|text| serde_json::from_str(&text).ok())
3866 .unwrap_or_default();
3867 all.extend(calls.iter().cloned());
3868 if let Some(parent) = self.spend_path().parent() {
3869 let _ = std::fs::create_dir_all(parent);
3870 }
3871 write_json_atomic(&self.spend_path(), &all)
3872 }
3873
3874 pub fn implementors_path(&self) -> PathBuf {
3880 self.root
3881 .join(STATE_DIR)
3882 .join("state")
3883 .join("implementors.json")
3884 }
3885
3886 pub fn read_implementors(&self) -> BTreeMap<i64, String> {
3887 std::fs::read_to_string(self.implementors_path())
3888 .ok()
3889 .and_then(|text| serde_json::from_str(&text).ok())
3890 .unwrap_or_default()
3891 }
3892
3893 pub fn remember_implementor(&self, issue: i64, agent: &str) {
3896 let mut known = self.read_implementors();
3897 known.insert(issue, agent.to_string());
3898 if let Some(parent) = self.implementors_path().parent() {
3899 let _ = std::fs::create_dir_all(parent);
3900 }
3901 if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
3902 logdim!("could not record who implemented #{issue}: {e}");
3903 }
3904 }
3905
3906 pub fn contested_path(&self) -> PathBuf {
3911 self.root
3912 .join(STATE_DIR)
3913 .join("state")
3914 .join("contested.json")
3915 }
3916
3917 pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
3919 std::fs::read_to_string(self.contested_path())
3920 .ok()
3921 .and_then(|text| serde_json::from_str(&text).ok())
3922 .unwrap_or_default()
3923 }
3924
3925 pub fn remember_contested(&self, items: &[ContestedItem]) {
3930 if items.is_empty() {
3931 return;
3932 }
3933 let mut known = self.read_contested();
3934 for item in items {
3935 known.insert(
3936 item.issue,
3937 Contested {
3938 title: item.title.clone(),
3939 positions: item.positions.clone(),
3940 reasons: item.reasons.clone(),
3941 },
3942 );
3943 }
3944 if let Some(parent) = self.contested_path().parent() {
3945 let _ = std::fs::create_dir_all(parent);
3946 }
3947 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
3948 logdim!("could not record the contested issues: {e}");
3949 }
3950 }
3951
3952 pub fn forget_contested(&self, numbers: &[i64]) {
3954 let mut known = self.read_contested();
3955 let before = known.len();
3956 known.retain(|number, _| !numbers.contains(number));
3957 if known.len() == before {
3958 return;
3959 }
3960 if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
3961 logdim!("could not update the contested issues: {e}");
3962 }
3963 }
3964
3965 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
3966 self.root
3967 .join(STATE_DIR)
3968 .join("state")
3969 .join(format!("checkin-{number}.json"))
3970 }
3971
3972 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
3987 let path = self.followups_path();
3988 let heading = format!("## {}", title.trim());
3989 for seen in [&path, &self.worked_followups_path()] {
3990 if let Ok(existing) = std::fs::read_to_string(seen) {
3991 if has_heading(&existing, title) {
3992 logdim!("follow-up already noted: {title}");
3993 return Followup::Covered(format!("note: {}", title.trim()));
3994 }
3995 }
3996 }
3997 if let Some(parent) = path.parent() {
3998 let _ = std::fs::create_dir_all(parent);
3999 }
4000 use std::io::Write;
4001 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4008 match std::fs::OpenOptions::new()
4009 .create(true)
4010 .append(true)
4011 .open(&path)
4012 {
4013 Ok(mut file) => match file.write_all(entry.as_bytes()) {
4014 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4015 Err(e) => {
4016 logdim!("could not write {}: {e}", path.display());
4017 Followup::Failed
4018 }
4019 },
4020 Err(e) => {
4021 logdim!("could not write {}: {e}", path.display());
4022 Followup::Failed
4023 }
4024 }
4025 }
4026
4027 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4032 let path = self.worked_followups_path();
4033 if let Some(parent) = path.parent() {
4034 let _ = std::fs::create_dir_all(parent);
4035 }
4036 use std::io::Write;
4037 let entry = format!(
4038 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4039 title.trim(),
4040 body.trim()
4041 );
4042 if let Ok(mut file) = std::fs::OpenOptions::new()
4043 .create(true)
4044 .append(true)
4045 .open(&path)
4046 {
4047 let _ = file.write_all(entry.as_bytes());
4048 }
4049 }
4050
4051 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4060 self.root
4061 .join(STATE_DIR)
4062 .join("reviews")
4063 .join(format!("pr-{number}.md"))
4064 }
4065
4066 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4072 let path = self.pending_comment_path(number);
4073 if let Some(parent) = path.parent() {
4074 std::fs::create_dir_all(parent)
4075 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4076 }
4077 std::fs::write(&path, text)
4078 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4079 Ok(path)
4080 }
4081
4082 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4083 std::fs::read_to_string(self.pending_comment_path(number)).ok()
4084 }
4085
4086 pub fn state_path(&self, number: i64) -> PathBuf {
4087 self.root
4088 .join(STATE_DIR)
4089 .join("state")
4090 .join(format!("pr-{number}.json"))
4091 }
4092
4093 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4094 let path = self.state_path(number);
4095 let text = std::fs::read_to_string(&path).ok()?;
4096 match serde_json::from_str(&text) {
4097 Ok(state) => Some(state),
4098 Err(_) => {
4099 logdim!("could not read {}, starting fresh", path.display());
4100 None
4101 }
4102 }
4103 }
4104
4105 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4106 if let Some(local) = self.read_local_state(pr.number) {
4107 return Some(local);
4108 }
4109 if self.state_store.writes_pr() {
4110 return self.read_pr_state(pr.number);
4111 }
4112 None
4113 }
4114
4115 pub(crate) fn read_state_for_head(
4116 &self,
4117 pr: &PrView,
4118 actual_head: &str,
4119 ) -> Option<PersistedState> {
4120 let local = self
4121 .state_store
4122 .writes_local()
4123 .then(|| self.read_local_state(pr.number))
4124 .flatten();
4125 let remote = self
4126 .state_store
4127 .writes_pr()
4128 .then(|| self.read_pr_state(pr.number))
4129 .flatten();
4130 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4131 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4132 self.remember_checkpoint(pr.number, checkpoint);
4133 }
4134 choose_state_for_head(candidates, actual_head)
4135 }
4136
4137 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4138 self.try_read_pr_state(number).ok().flatten()
4139 }
4140
4141 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4142 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4143 if let Some(state) = parse_state_comment(&body) {
4144 return Ok(Some(state));
4145 }
4146 }
4147 Ok(None)
4148 }
4149
4150 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4151 let remote_state = if self.state_store.writes_pr() {
4152 self.try_read_pr_state(number)
4153 } else {
4154 Ok(None)
4155 };
4156 self.write_state_after_remote_read(number, state, remote_state)
4157 }
4158
4159 fn write_state_after_remote_read(
4160 &self,
4161 number: i64,
4162 state: &PersistedState,
4163 remote_state: Result<Option<PersistedState>>,
4164 ) -> Result<()> {
4165 let remote_checkpoint = if self.state_store.writes_pr() {
4166 self.record_failed_write(remote_state)?
4167 .map(|saved| saved.checkpoint)
4168 .unwrap_or_default()
4169 } else {
4170 0
4171 };
4172 let local_checkpoint = self
4173 .state_store
4174 .writes_local()
4175 .then(|| self.read_local_state(number))
4176 .flatten()
4177 .map(|saved| saved.checkpoint)
4178 .unwrap_or_default();
4179 let mut stamped = state.clone();
4180 stamped.checkpoint = state
4181 .checkpoint
4182 .max(local_checkpoint)
4183 .max(remote_checkpoint)
4184 .max(self.remembered_checkpoint(number))
4185 .saturating_add(1);
4186 self.remember_checkpoint(number, stamped.checkpoint);
4187 if self.state_store.writes_local() {
4188 write_json_atomic(&self.state_path(number), &stamped)?;
4189 }
4190 if self.state_store.writes_pr() {
4191 self.write_pr_state(number, &stamped)?;
4192 }
4193 Ok(())
4194 }
4195
4196 fn remembered_checkpoint(&self, number: i64) -> u64 {
4197 self.checkpoints
4198 .lock()
4199 .unwrap_or_else(std::sync::PoisonError::into_inner)
4200 .get(&number)
4201 .copied()
4202 .unwrap_or_default()
4203 }
4204
4205 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4206 let mut checkpoints = self
4207 .checkpoints
4208 .lock()
4209 .unwrap_or_else(std::sync::PoisonError::into_inner);
4210 let saved = checkpoints.entry(number).or_default();
4211 *saved = (*saved).max(checkpoint);
4212 }
4213
4214 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4215 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4219 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4220 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4221 if let Some(id) = comment_id {
4222 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4223 let field = format!("body={body}");
4224 let written = self
4225 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4226 .map(|_| ());
4227 return self.record_write(written);
4228 }
4229 let written = self
4230 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4231 .map(|_| ());
4232 self.record_write(written)
4233 }
4234
4235 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4243 self.try_issue_comments(number).unwrap_or_default()
4244 }
4245
4246 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4247 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4248 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4249 }
4250
4251 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4258 let viewer = self.viewer_login()?.to_string();
4259 let mut mine = Vec::new();
4260 let mut theirs = Vec::new();
4261 for comment in self.try_issue_comments(number)? {
4262 let Some(body) = comment.get("body").and_then(Value::as_str) else {
4263 continue;
4264 };
4265 if !body.contains("spar:state") {
4266 continue;
4267 }
4268 let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4269 continue;
4270 };
4271 let author = comment
4272 .get("user")
4273 .and_then(|u| u.get("login"))
4274 .and_then(Value::as_str)
4275 .unwrap_or_default();
4276 if is_state_comment(body, author, &viewer) {
4277 mine.push((id, body.to_string()));
4278 } else {
4279 theirs.push(format!("@{author}"));
4280 }
4281 }
4282 if !theirs.is_empty() {
4283 logdim!(
4284 "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4285 own state block is patched or deleted.",
4286 theirs.len(),
4287 theirs.join(", ")
4288 );
4289 }
4290 Ok(mine)
4291 }
4292
4293 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4294 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4295 }
4296
4297 pub fn clear_state(&self, number: i64) {
4299 let path = self.state_path(number);
4300 let _ = std::fs::remove_file(&path);
4301 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4302 }
4303
4304 pub fn prune_state(&self) -> Vec<String> {
4308 let base = self.root.join(STATE_DIR).join("state");
4309 let Ok(entries) = std::fs::read_dir(&base) else {
4310 return Vec::new();
4311 };
4312 let mut names: Vec<String> = entries
4313 .flatten()
4314 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4315 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4316 .collect();
4317 names.sort();
4318
4319 let mut removed = Vec::new();
4320 for name in names {
4321 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4322 continue;
4323 };
4324 if is_finished(&self.pr_state(number)) {
4325 let _ = std::fs::remove_file(base.join(&name));
4326 removed.push(format!("state {name}"));
4327 }
4328 }
4329 removed
4330 }
4331
4332 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4336 #[derive(Deserialize)]
4337 struct Row {
4338 number: i64,
4339 }
4340 let numbers = match numbers {
4341 Some(numbers) => numbers,
4342 None => {
4343 let listed: Result<Vec<i64>> = (|| {
4344 let text = self.gh(&[
4345 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4346 ])?;
4347 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4348 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4349 Ok(rows.into_iter().map(|row| row.number).collect())
4350 })();
4351 match self.record_failed_write(listed) {
4352 Ok(numbers) => numbers,
4353 Err(e) => {
4354 logdim!("could not inspect pull requests for state cleanup: {e}");
4355 return Vec::new();
4356 }
4357 }
4358 }
4359 };
4360
4361 let mut removed = Vec::new();
4362 for number in numbers {
4363 let state = match self.record_failed_write(self.try_pr_state(number)) {
4364 Ok(state) => state,
4365 Err(e) => {
4366 logdim!("could not inspect PR #{number} for state cleanup: {e}");
4367 continue;
4368 }
4369 };
4370 if !is_finished(&state) {
4371 continue;
4372 }
4373 let comments = match self.record_failed_write(self.try_state_comments(number)) {
4374 Ok(comments) => comments,
4375 Err(e) => {
4376 logdim!("could not inspect state comments on PR #{number}: {e}");
4377 continue;
4378 }
4379 };
4380 for (id, _) in comments {
4381 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4382 let deleted = self
4383 .gh(&["api", "-X", "DELETE", &path, "--silent"])
4384 .map(|_| ());
4385 match self.record_write(deleted) {
4386 Ok(()) => removed.push(format!("state comment on PR #{number}")),
4387 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
4388 }
4389 }
4390 }
4391 removed
4392 }
4393
4394 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
4401 let base = self.root.join(WORKTREE_DIR);
4402 let mut removed = Vec::new();
4403 let known = self.known_branches();
4404
4405 if let Ok(entries) = std::fs::read_dir(&base) {
4406 let mut names: Vec<String> = entries
4407 .flatten()
4408 .filter(|e| e.path().is_dir())
4409 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4410 .collect();
4411 names.sort();
4412
4413 for name in names {
4414 if let Some(rest) = name.strip_prefix("review-") {
4417 let number: i64 = rest.parse().unwrap_or(-1);
4418 if !(force_all || is_finished(&self.pr_state(number))) {
4419 continue;
4420 }
4421 let path = base.join(&name);
4422 if force_all {
4423 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4424 if !belongs {
4425 return Ok(false);
4426 }
4427 let local_ref = review_ref(number);
4428 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4429 return Ok(false);
4430 }
4431 let head = self.head_oid_checked(&path)?;
4432 let recorded = self
4433 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4434 .trim()
4435 .to_string();
4436 Ok(head == recorded)
4437 });
4438 match owned {
4439 Ok(true) => {}
4440 Ok(false) => {
4441 logdim!(
4442 "kept {} because no matching SPAR review reference proves \
4443 ownership",
4444 path.display()
4445 );
4446 continue;
4447 }
4448 Err(e) => {
4449 logdim!(
4450 "kept {} because review ownership could not be verified: {}",
4451 path.display(),
4452 e.last_line()
4453 );
4454 continue;
4455 }
4456 }
4457 } else {
4458 if let Err(e) = self.refuse_review_worktree_changes(number) {
4459 logdim!(
4460 "kept {} because its review state could not be verified as \
4461 disposable: {}",
4462 path.display(),
4463 e.last_line()
4464 );
4465 continue;
4466 }
4467 }
4468 if force_all {
4469 if self.remove_worktree_at_force(&path) {
4470 self.git_try(&["update-ref", "-d", &review_ref(number)]);
4471 }
4472 } else {
4473 self.release_review_worktree(number);
4474 }
4475 if !path.exists() {
4476 removed.push(name);
4477 }
4478 continue;
4479 }
4480 let branch = format!("{}{name}", self.branch_prefix);
4481 if !(force_all || self.worktree_is_done(&branch)) {
4482 continue;
4483 }
4484 if !known.contains_key(&branch) {
4485 logdim!("kept {branch} because it has no branch record");
4486 continue;
4487 }
4488 let path = base.join(&name);
4489 if !force_all {
4490 match self.has_recoverable_work(&path) {
4491 Ok(true) => {
4492 logdim!(
4493 "kept {} because it contains uncommitted changes or ignored files",
4494 path.display()
4495 );
4496 continue;
4497 }
4498 Err(e) => {
4499 logdim!(
4500 "kept {} because its Git state could not be checked: {}",
4501 path.display(),
4502 e.last_line()
4503 );
4504 continue;
4505 }
4506 Ok(false) => {}
4507 }
4508 match self.branch_deletion_is_safe(&branch) {
4509 Ok(true) => {}
4510 Ok(false) => {
4511 logdim!(
4512 "kept {branch} because no surviving ref preserves its tip or \
4513 reflog-only commits"
4514 );
4515 continue;
4516 }
4517 Err(e) => {
4518 logdim!(
4519 "kept {branch} because preservation could not be verified: {}",
4520 e.last_line()
4521 );
4522 continue;
4523 }
4524 }
4525 }
4526 let removed_worktree = if force_all {
4527 self.remove_worktree_at_force(&path)
4528 } else {
4529 match self.remove_worktree_at(&path) {
4530 Ok(removed) => removed,
4531 Err(error) => {
4532 logdim!(
4533 "kept {branch} and {} because removal did not reach a confirmed \
4534 quiet point: {}",
4535 path.display(),
4536 error.last_line()
4537 );
4538 false
4539 }
4540 }
4541 };
4542 if !removed_worktree {
4543 continue;
4544 }
4545 if force_all {
4546 self.git_try(&["branch", "-D", &branch]);
4547 self.forget_branch(&branch);
4548 } else {
4549 match self.delete_branch_if_safe(&branch) {
4550 Ok(true) => self.forget_branch(&branch),
4551 Ok(false) => logdim!(
4552 "kept {branch} because its tip or reflog changed before deletion"
4553 ),
4554 Err(error) => logdim!(
4555 "kept {branch} because deletion safety could not be rechecked: {}",
4556 error.last_line()
4557 ),
4558 }
4559 }
4560 removed.push(name);
4561 }
4562 }
4563 removed.extend(self.prune_branches(force_all));
4564 removed
4565 }
4566
4567 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
4574 let known = self.known_branches();
4575 let branches: Vec<String> = known.keys().cloned().collect();
4576 if branches.is_empty() {
4577 return Vec::new();
4578 }
4579
4580 let checked_out: Vec<String> = self
4581 .git_try(&["worktree", "list", "--porcelain"])
4582 .lines()
4583 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
4584 .collect();
4585
4586 let existing: Vec<String> = self
4589 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
4590 .lines()
4591 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
4592 .collect();
4593
4594 let mut removed = Vec::new();
4595 for branch in branches {
4596 if !existing.contains(&branch) {
4597 self.forget_branch(&branch); continue;
4599 }
4600 if checked_out.contains(&branch) {
4601 continue;
4602 }
4603 if !(force_all || self.worktree_is_done(&branch)) {
4604 continue;
4605 }
4606 if !force_all {
4607 let Some(_record) = known.get(&branch) else {
4608 continue;
4609 };
4610 match self.branch_deletion_is_safe(&branch) {
4611 Ok(true) => {}
4612 Ok(false) => {
4613 logdim!(
4614 "kept {branch} because no surviving ref preserves its tip or \
4615 reflog-only commits"
4616 );
4617 continue;
4618 }
4619 Err(e) => {
4620 logdim!(
4621 "kept {branch} because preservation could not be verified: {}",
4622 e.last_line()
4623 );
4624 continue;
4625 }
4626 }
4627 }
4628 let deleted = if force_all {
4629 self.git(&["branch", "-D", &branch]).map(|_| true)
4630 } else {
4631 self.delete_branch_if_safe(&branch)
4632 };
4633 match deleted {
4634 Ok(true) => {
4635 self.forget_branch(&branch);
4636 removed.push(format!("branch {branch}"));
4637 }
4638 Ok(false) => {
4639 logdim!("kept {branch} because its tip or reflog changed before deletion");
4640 }
4641 Err(e) => {
4642 logdim!("could not delete {branch}: {}", e.last_line());
4645 }
4646 }
4647 }
4648 removed
4649 }
4650
4651 fn worktree_is_done(&self, branch: &str) -> bool {
4653 #[derive(Deserialize)]
4654 struct Row {
4655 state: String,
4656 }
4657 let entry = branch
4658 .strip_prefix(self.branch_prefix.as_str())
4659 .unwrap_or(branch);
4660 if let Some(rest) = entry.strip_prefix("pr-") {
4661 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
4662 }
4663 if entry.starts_with("issue-") || entry.starts_with("split-") {
4667 let text = self.gh_try(&[
4668 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
4669 ]);
4670 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4671 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
4672 }
4673 false
4674 }
4675}
4676
4677pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
4687 let root = std::fs::canonicalize(cwd)
4688 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
4689 let mut files = BTreeMap::new();
4690 let mut visited = BTreeSet::new();
4691 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
4692 Ok(AttributeState { files })
4693}
4694
4695fn collect_attribute_files(
4696 root: &Path,
4697 repository: &Path,
4698 prefix: &Path,
4699 visited: &mut BTreeSet<PathBuf>,
4700 files: &mut BTreeMap<PathBuf, [u8; 32]>,
4701) -> Result<()> {
4702 let canonical = std::fs::canonicalize(repository)
4703 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
4704 if !visited.insert(canonical) {
4705 bail!("submodule recursion revisited {}", repository.display());
4706 }
4707 let entries = index_entries(repository)?;
4708 let mut paths: BTreeSet<PathBuf> = entries
4709 .iter()
4710 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
4711 .map(|entry| entry.path.clone())
4712 .collect();
4713 let untracked = run_git_bytes(
4714 repository,
4715 &[
4716 "ls-files",
4717 "--others",
4718 "-z",
4719 "--",
4720 ".gitattributes",
4721 ":(glob)**/.gitattributes",
4722 ],
4723 )?;
4724 if !untracked.is_empty() && !untracked.ends_with(&[0]) {
4725 bail!(
4726 "git returned an unterminated attribute-file listing for {}",
4727 repository.display()
4728 );
4729 }
4730 for raw in untracked
4731 .split(|byte| *byte == 0)
4732 .filter(|record| !record.is_empty())
4733 {
4734 paths.insert(safe_git_path(raw, "attribute")?);
4735 }
4736 for path in paths {
4737 let from_root = prefix.join(&path);
4738 let state = attribute_file_fingerprint(&root.join(&from_root))?;
4739 files.insert(from_root, state);
4740 }
4741 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
4742 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
4743 continue;
4744 };
4745 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
4746 }
4747 Ok(())
4748}
4749
4750pub(crate) fn uncertain_worktree_change(
4753 cwd: &Path,
4754 message: impl Into<String>,
4755) -> crate::error::SparError {
4756 let message = message.into();
4757 let marker = write_recovery_marker(cwd, &message);
4758 let note = match marker {
4759 Ok(path) => format!(" Recovery marker: {}.", path.display()),
4760 Err(e) => format!(
4761 " A recovery marker could not be written: {}.",
4762 e.last_line()
4763 ),
4764 };
4765 crate::error::SparError::uncertain_write(format!("{message}{note}"))
4766}
4767
4768fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
4769 use std::sync::atomic::{AtomicU32, Ordering};
4770 static NEXT: AtomicU32 = AtomicU32::new(0);
4771 for _ in 0..1000 {
4772 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
4773 let path = cwd.join(format!(
4774 ".spar-recovery-needed-{}-{serial}",
4775 std::process::id()
4776 ));
4777 let mut options = OpenOptions::new();
4778 options.write(true).create_new(true);
4779 #[cfg(unix)]
4780 {
4781 use std::os::unix::fs::OpenOptionsExt;
4782 options.mode(0o600);
4783 }
4784 match options.open(&path) {
4785 Ok(mut file) => {
4786 file.write_all(detail.as_bytes())
4787 .and_then(|_| file.write_all(b"\n"))
4788 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4789 return Ok(path);
4790 }
4791 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
4792 Err(e) => {
4793 return Err(spar_err!(
4794 "could not create a recovery marker in {}: {e}",
4795 cwd.display()
4796 ))
4797 }
4798 }
4799 }
4800 bail!(
4801 "could not choose a free recovery marker name in {}",
4802 cwd.display()
4803 )
4804}
4805
4806fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
4811 let mut argv = vec![
4812 "git".to_string(),
4813 "-c".to_string(),
4814 "maintenance.auto=false".to_string(),
4815 "-c".to_string(),
4816 "gc.auto=0".to_string(),
4817 ];
4818 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4819 argv
4820}
4821
4822fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
4823 let mut argv = git_without_maintenance_argv(&[]);
4824 argv.extend([
4825 "-c".to_string(),
4826 "core.fsmonitor=".to_string(),
4827 "-c".to_string(),
4828 "commit.gpgsign=false".to_string(),
4829 "-c".to_string(),
4830 "core.hooksPath=/dev/null".to_string(),
4831 ]);
4832 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4833 argv
4834}
4835
4836pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
4843 let root = std::fs::canonicalize(cwd)
4844 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
4845 let mut files = BTreeMap::new();
4846 let mut ignored = BTreeSet::new();
4847 let mut visited = BTreeSet::new();
4848 collect_untracked_files(
4849 &root,
4850 &root,
4851 Path::new(""),
4852 &mut visited,
4853 &mut files,
4854 &mut ignored,
4855 )?;
4856 Ok(IgnoredState { files, ignored })
4857}
4858
4859fn collect_untracked_files(
4860 root: &Path,
4861 repository: &Path,
4862 prefix: &Path,
4863 visited: &mut BTreeSet<PathBuf>,
4864 files: &mut BTreeMap<PathBuf, UntrackedFile>,
4865 ignored: &mut BTreeSet<PathBuf>,
4866) -> Result<()> {
4867 let canonical = std::fs::canonicalize(repository)
4868 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
4869 if !visited.insert(canonical.clone()) {
4870 bail!("submodule recursion revisited {}", canonical.display());
4871 }
4872 let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
4873 if !listed.is_empty() && !listed.ends_with(&[0]) {
4874 bail!(
4875 "git returned an unterminated untracked-file list for {}",
4876 repository.display()
4877 );
4878 }
4879
4880 for raw in listed
4881 .split(|byte| *byte == 0)
4882 .filter(|raw| !raw.is_empty())
4883 {
4884 let (relative, nested) = untracked_record(raw, "untracked")?;
4885 let from_root = prefix.join(&relative);
4886 let absolute = root.join(&from_root);
4887 let fingerprint = if nested {
4888 nested_repository_fingerprint(&absolute)?
4889 } else {
4890 ignored_file_fingerprint(&absolute)?
4891 };
4892 if files.insert(from_root.clone(), fingerprint).is_some() {
4893 bail!(
4894 "git returned the untracked path more than once: {:?}",
4895 from_root
4896 );
4897 }
4898 }
4899
4900 let ignored_listed = run_git_bytes(
4901 repository,
4902 &[
4903 "ls-files",
4904 "--others",
4905 "--ignored",
4906 "--exclude-standard",
4907 "-z",
4908 ],
4909 )?;
4910 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
4911 bail!(
4912 "git returned an unterminated ignored-file list for {}",
4913 repository.display()
4914 );
4915 }
4916 for raw in ignored_listed
4917 .split(|byte| *byte == 0)
4918 .filter(|raw| !raw.is_empty())
4919 {
4920 let (relative, _) = untracked_record(raw, "ignored")?;
4921 let from_root = prefix.join(relative);
4922 if !files.contains_key(&from_root) {
4923 bail!(
4924 "git classified an unlisted path as ignored: {:?}",
4925 from_root
4926 );
4927 }
4928 if !ignored.insert(from_root.clone()) {
4929 bail!(
4930 "git returned the ignored path more than once: {:?}",
4931 from_root
4932 );
4933 }
4934 }
4935
4936 for link in gitlinks(repository)? {
4937 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
4938 continue;
4939 };
4940 collect_untracked_files(
4941 root,
4942 &submodule,
4943 &prefix.join(&link.path),
4944 visited,
4945 files,
4946 ignored,
4947 )?;
4948 }
4949 Ok(())
4950}
4951
4952fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
4953 let argv = git_without_automation_argv(args);
4954 proc::run_bytes(
4955 &argv,
4956 &ExecOpts::new()
4957 .cwd(cwd)
4958 .timeout_secs(30)
4959 .stop_descendants(true),
4960 )
4961}
4962
4963fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
4964 let argv = git_without_automation_argv(args);
4965 proc::run(
4966 &argv,
4967 &ExecOpts::new()
4968 .cwd(cwd)
4969 .timeout_secs(30)
4970 .stop_descendants(true),
4971 )
4972}
4973
4974fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
4975 let path = path.to_str().ok_or_else(|| {
4976 spar_err!(
4977 "cannot verify filtered content for a non-UTF-8 path in {}",
4978 cwd.display()
4979 )
4980 })?;
4981 let path_arg = format!("--path={path}");
4982 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
4983 Ok(Sha256::digest(bytes).into())
4984}
4985
4986fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
4987 let relative = path_from_git_bytes(raw)?;
4988 if relative.is_absolute()
4989 || relative.components().any(|component| {
4990 matches!(
4991 component,
4992 std::path::Component::ParentDir
4993 | std::path::Component::RootDir
4994 | std::path::Component::Prefix(_)
4995 )
4996 })
4997 {
4998 bail!("git returned an unsafe {kind} path: {:?}", relative);
4999 }
5000 Ok(relative)
5001}
5002
5003fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5012 let nested = raw.last() == Some(&b'/');
5013 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5014 if trimmed.is_empty() {
5015 bail!("git returned an empty {kind} path");
5016 }
5017 Ok((safe_git_path(trimmed, kind)?, nested))
5018}
5019
5020fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5021 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5022 if !listed.is_empty() && !listed.ends_with(&[0]) {
5023 bail!(
5024 "git returned an unterminated index listing for {}",
5025 cwd.display()
5026 );
5027 }
5028 let mut entries = Vec::new();
5029 for record in listed
5030 .split(|byte| *byte == 0)
5031 .filter(|record| !record.is_empty())
5032 {
5033 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5034 bail!(
5035 "git returned a malformed index record for {}",
5036 cwd.display()
5037 );
5038 };
5039 let header = &record[..tab];
5040 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5041 if fields.len() != 3 {
5042 bail!(
5043 "git returned a malformed index header for {}",
5044 cwd.display()
5045 );
5046 }
5047 if fields[2] != b"0" {
5048 continue;
5049 }
5050 let mode = std::str::from_utf8(fields[0])
5051 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5052 .to_string();
5053 let oid = std::str::from_utf8(fields[1])
5054 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5055 .to_string();
5056 entries.push(IndexEntry {
5057 path: safe_git_path(&record[tab + 1..], "index")?,
5058 mode,
5059 oid,
5060 });
5061 }
5062 Ok(entries)
5063}
5064
5065fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5066 let untracked = run_git_bytes(
5067 cwd,
5068 &[
5069 "ls-files",
5070 "--others",
5071 "-z",
5072 "--",
5073 ".gitattributes",
5074 ":(glob)**/.gitattributes",
5075 ],
5076 )?;
5077 if !untracked.is_empty() {
5078 return Ok(true);
5079 }
5080
5081 let index = index_entries(cwd)?
5082 .into_iter()
5083 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5084 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5085 .collect::<BTreeMap<_, _>>();
5086 let head = tree_entries(cwd, "HEAD")?
5087 .into_iter()
5088 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5089 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5090 .collect::<BTreeMap<_, _>>();
5091 if index != head {
5092 return Ok(true);
5093 }
5094
5095 let effective = check_attributes(cwd, index.keys().cloned())?;
5096 let config = CheckoutConfig::read(cwd)?;
5097 for (path, (_mode, oid)) in index {
5098 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5099 return Ok(true);
5100 };
5101 let attributes = effective
5102 .get(&path)
5103 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5104 if allows_expected_crlf(&config, attributes)? {
5105 if worktree.mode == "120000" {
5106 return Ok(true);
5107 }
5108 let (normalized, every_lf_was_crlf) =
5109 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5110 if !every_lf_was_crlf || normalized != oid {
5111 return Ok(true);
5112 }
5113 } else if worktree.raw_oid != oid {
5114 return Ok(true);
5115 }
5116 }
5117 Ok(false)
5118}
5119
5120fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5121 Ok(index_entries(cwd)?
5122 .into_iter()
5123 .filter(|entry| entry.mode == "160000")
5124 .map(|entry| Gitlink {
5125 path: entry.path,
5126 oid: entry.oid,
5127 })
5128 .collect())
5129}
5130
5131fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5132 let mut tracked = BTreeMap::new();
5133 for entry in index_entries(cwd)? {
5134 if entry.mode == "160000" {
5135 continue;
5136 }
5137 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5138 tracked.insert(
5139 entry.path,
5140 TrackedEntry {
5141 index_mode: entry.mode,
5142 index_oid: entry.oid,
5143 worktree,
5144 },
5145 );
5146 }
5147 Ok(tracked)
5148}
5149
5150fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5151 let metadata = match std::fs::symlink_metadata(path) {
5152 Ok(metadata) => metadata,
5153 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5154 Err(e) => {
5155 return Err(spar_err!(
5156 "could not inspect tracked file {}: {e}",
5157 path.display()
5158 ))
5159 }
5160 };
5161 let mut fingerprint = Sha256::new();
5162 if metadata.file_type().is_symlink() {
5163 let target = std::fs::read_link(path)
5164 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5165 let bytes = os_str_bytes(target.as_os_str())?;
5166 fingerprint.update(b"symlink\0");
5167 fingerprint.update(&bytes);
5168 let content = Sha256::digest(&bytes).into();
5169 return Ok(Some(WorktreeFile {
5170 mode: "120000".to_string(),
5171 #[cfg(unix)]
5172 permissions: 0,
5173 raw_oid: git_blob_oid(oid_len, &bytes)?,
5174 fingerprint: fingerprint.finalize().into(),
5175 content,
5176 }));
5177 }
5178 if !metadata.is_file() {
5179 bail!("tracked path {} is not a file or symlink", path.display());
5180 }
5181
5182 let mut options = OpenOptions::new();
5183 options.read(true);
5184 #[cfg(unix)]
5185 {
5186 use std::os::unix::fs::OpenOptionsExt;
5187 options.custom_flags(libc::O_NOFOLLOW);
5188 }
5189 let mut file = options
5190 .open(path)
5191 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5192 let before = file
5193 .metadata()
5194 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5195 let mode = tracked_file_mode(&before);
5196 #[cfg(unix)]
5197 let permissions = {
5198 use std::os::unix::fs::MetadataExt;
5199 before.mode() & 0o7777
5200 };
5201 fingerprint.update(b"file\0");
5202 fingerprint.update(mode.as_bytes());
5203 #[cfg(unix)]
5204 fingerprint.update(permissions.to_le_bytes());
5205 fingerprint.update(before.len().to_le_bytes());
5206 let mut content = Sha256::new();
5207 let header = format!("blob {}\0", before.len());
5208 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5209 let mut buf = [0u8; 64 * 1024];
5210 loop {
5211 let read = file
5212 .read(&mut buf)
5213 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5214 if read == 0 {
5215 break;
5216 }
5217 fingerprint.update(&buf[..read]);
5218 content.update(&buf[..read]);
5219 object.update(&buf[..read]);
5220 }
5221 let after = file
5222 .metadata()
5223 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5224 if before.len() != after.len()
5225 || before.modified().ok() != after.modified().ok()
5226 || before.permissions() != after.permissions()
5227 {
5228 bail!(
5229 "tracked file {} changed while it was being inspected",
5230 path.display()
5231 );
5232 }
5233 let current = std::fs::symlink_metadata(path)
5234 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5235 if !same_file(&after, ¤t) {
5236 bail!(
5237 "tracked file {} was replaced while it was being inspected",
5238 path.display()
5239 );
5240 }
5241 Ok(Some(WorktreeFile {
5242 mode,
5243 #[cfg(unix)]
5244 permissions,
5245 raw_oid: object.finish(),
5246 fingerprint: fingerprint.finalize().into(),
5247 content: content.finalize().into(),
5248 }))
5249}
5250
5251fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5252 let metadata = std::fs::symlink_metadata(path)
5253 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5254 let mut digest = Sha256::new();
5255 if metadata.file_type().is_symlink() {
5256 digest.update(b"symlink\0");
5257 let target = std::fs::read_link(path)
5258 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5259 digest.update(os_str_bytes(target.as_os_str())?);
5260 return Ok(digest.finalize().into());
5261 }
5262 if !metadata.is_file() {
5263 bail!("attribute path {} is not a file or symlink", path.display());
5264 }
5265 let mut options = OpenOptions::new();
5266 options.read(true);
5267 #[cfg(unix)]
5268 {
5269 use std::os::unix::fs::OpenOptionsExt;
5270 options.custom_flags(libc::O_NOFOLLOW);
5271 }
5272 let mut file = options
5273 .open(path)
5274 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5275 let before = file
5276 .metadata()
5277 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5278 digest.update(b"file\0");
5279 let mut buf = [0u8; 64 * 1024];
5280 loop {
5281 let read = file
5282 .read(&mut buf)
5283 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5284 if read == 0 {
5285 break;
5286 }
5287 digest.update(&buf[..read]);
5288 }
5289 let after = file
5290 .metadata()
5291 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5292 let current = std::fs::symlink_metadata(path)
5293 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5294 if before.len() != after.len()
5295 || before.modified().ok() != after.modified().ok()
5296 || !same_file(&after, ¤t)
5297 {
5298 bail!(
5299 "attribute file {} changed while it was being inspected",
5300 path.display()
5301 );
5302 }
5303 Ok(digest.finalize().into())
5304}
5305
5306enum ObjectHasher {
5307 Sha1(Sha1),
5308 Sha256(Sha256),
5309}
5310
5311impl ObjectHasher {
5312 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5313 let mut hasher = match oid_len {
5314 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5315 64 => Self::Sha256(Sha256::new()),
5316 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5317 };
5318 hasher.update(header);
5319 Ok(hasher)
5320 }
5321
5322 fn update(&mut self, bytes: &[u8]) {
5323 match self {
5324 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5325 Self::Sha256(hasher) => hasher.update(bytes),
5326 }
5327 }
5328
5329 fn finish(self) -> String {
5330 let bytes = match self {
5331 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
5332 Self::Sha256(hasher) => hasher.finalize().to_vec(),
5333 };
5334 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
5335 }
5336}
5337
5338fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
5339 let header = format!("blob {}\0", bytes.len());
5340 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
5341 hasher.update(bytes);
5342 Ok(hasher.finish())
5343}
5344
5345fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
5346 let mut first = open_regular_file(path)?;
5347 let first_before = first
5348 .metadata()
5349 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5350 let mut raw_len = 0u64;
5351 let mut crlf_pairs = 0u64;
5352 let mut previous_was_cr = false;
5353 let mut every_lf_was_crlf = true;
5354 let mut buf = [0u8; 64 * 1024];
5355 loop {
5356 let read = first
5357 .read(&mut buf)
5358 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5359 if read == 0 {
5360 break;
5361 }
5362 raw_len = raw_len
5363 .checked_add(read as u64)
5364 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5365 for byte in &buf[..read] {
5366 if *byte == b'\n' {
5367 if previous_was_cr {
5368 crlf_pairs += 1;
5369 } else {
5370 every_lf_was_crlf = false;
5371 }
5372 }
5373 previous_was_cr = *byte == b'\r';
5374 }
5375 }
5376 let first_after = first
5377 .metadata()
5378 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5379 let current = std::fs::symlink_metadata(path)
5380 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5381 if raw_len != first_before.len()
5382 || !stable_file_metadata(&first_before, &first_after)
5383 || !stable_file_metadata(&first_after, ¤t)
5384 {
5385 bail!(
5386 "tracked file {} changed while line endings were inspected",
5387 path.display()
5388 );
5389 }
5390
5391 let normalized_len = raw_len
5392 .checked_sub(crlf_pairs)
5393 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5394 let header = format!("blob {normalized_len}\0");
5395 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5396 let mut second = open_regular_file(path)?;
5397 let second_before = second
5398 .metadata()
5399 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5400 if !stable_file_metadata(&first_after, &second_before) {
5401 bail!(
5402 "tracked file {} changed between line-ending checks",
5403 path.display()
5404 );
5405 }
5406 let mut pending_cr = false;
5407 loop {
5408 let read = second
5409 .read(&mut buf)
5410 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5411 if read == 0 {
5412 break;
5413 }
5414 for byte in &buf[..read] {
5415 if pending_cr {
5416 if *byte == b'\n' {
5417 object.update(b"\n");
5418 pending_cr = false;
5419 continue;
5420 }
5421 object.update(b"\r");
5422 pending_cr = false;
5423 }
5424 if *byte == b'\r' {
5425 pending_cr = true;
5426 } else {
5427 object.update(std::slice::from_ref(byte));
5428 }
5429 }
5430 }
5431 if pending_cr {
5432 object.update(b"\r");
5433 }
5434 let second_after = second
5435 .metadata()
5436 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5437 let current = std::fs::symlink_metadata(path)
5438 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5439 if !stable_file_metadata(&second_before, &second_after)
5440 || !stable_file_metadata(&second_after, ¤t)
5441 {
5442 bail!(
5443 "tracked file {} changed while line endings were hashed",
5444 path.display()
5445 );
5446 }
5447 Ok((object.finish(), every_lf_was_crlf))
5448}
5449
5450fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5451 let mut options = OpenOptions::new();
5452 options.read(true);
5453 #[cfg(unix)]
5454 {
5455 use std::os::unix::fs::OpenOptionsExt;
5456 options.custom_flags(libc::O_NOFOLLOW);
5457 }
5458 let file = options
5459 .open(path)
5460 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5461 let metadata = file
5462 .metadata()
5463 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5464 if !metadata.is_file() {
5465 bail!("tracked path {} is not a regular file", path.display());
5466 }
5467 Ok(file)
5468}
5469
5470fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5471 if !same_file(left, right)
5472 || left.len() != right.len()
5473 || left.modified().ok() != right.modified().ok()
5474 || left.permissions() != right.permissions()
5475 {
5476 return false;
5477 }
5478 #[cfg(unix)]
5479 {
5480 use std::os::unix::fs::MetadataExt;
5481 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5482 }
5483 #[cfg(not(unix))]
5484 {
5485 left.created().ok() == right.created().ok()
5486 }
5487}
5488
5489const ATTRIBUTE_BATCH: usize = 1000;
5496
5497fn check_attributes(
5498 cwd: &Path,
5499 paths: impl IntoIterator<Item = PathBuf>,
5500) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5501 let paths = paths.into_iter().collect::<BTreeSet<_>>();
5502 if paths.is_empty() {
5503 return Ok(BTreeMap::new());
5504 }
5505 let ordered = paths.into_iter().collect::<Vec<_>>();
5506 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5507 for batch in ordered.chunks(ATTRIBUTE_BATCH) {
5508 values.extend(check_attribute_batch(cwd, batch)?);
5509 }
5510 Ok(values)
5511}
5512
5513fn check_attribute_batch(
5514 cwd: &Path,
5515 batch: &[PathBuf],
5516) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5517 const NAMES: [&str; 6] = [
5518 "filter",
5519 "working-tree-encoding",
5520 "ident",
5521 "text",
5522 "eol",
5523 "crlf",
5524 ];
5525 let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
5526 let mut input = String::new();
5527 for path in &paths {
5528 let path = path.to_str().ok_or_else(|| {
5529 spar_err!(
5530 "cannot inspect attributes for a non-UTF-8 path in {}",
5531 cwd.display()
5532 )
5533 })?;
5534 input.push_str(path);
5535 input.push('\0');
5536 }
5537 let argv = git_without_automation_argv(&[
5538 "check-attr",
5539 "-z",
5540 "--cached",
5541 "--stdin",
5542 "filter",
5543 "working-tree-encoding",
5544 "ident",
5545 "text",
5546 "eol",
5547 "crlf",
5548 ]);
5549 let output = proc::run_bytes(
5550 &argv,
5551 &ExecOpts::new()
5552 .cwd(cwd)
5553 .timeout_secs(30)
5554 .stdin(input)
5555 .stop_descendants(true),
5556 )?;
5557 if !output.is_empty() && !output.ends_with(&[0]) {
5558 bail!(
5559 "git returned an unterminated attribute result for {}",
5560 cwd.display()
5561 );
5562 }
5563 let fields = output
5564 .split(|byte| *byte == 0)
5565 .filter(|field| !field.is_empty())
5566 .collect::<Vec<_>>();
5567 if fields.len() != paths.len() * NAMES.len() * 3 {
5568 bail!(
5569 "git returned an unexpected attribute result for {}",
5570 cwd.display()
5571 );
5572 }
5573 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5574 for record in fields.chunks_exact(3) {
5575 let path = safe_git_path(record[0], "attribute")?;
5576 if !paths.contains(&path) {
5577 bail!(
5578 "git returned attributes for the wrong path in {}",
5579 cwd.display()
5580 );
5581 }
5582 let name = std::str::from_utf8(record[1])
5583 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
5584 let value = std::str::from_utf8(record[2])
5585 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
5586 values
5587 .entry(path)
5588 .or_default()
5589 .insert(name.to_string(), value.to_string());
5590 }
5591 if paths.iter().any(|path| {
5592 values
5593 .get(path)
5594 .is_none_or(|attributes| attributes.len() != NAMES.len())
5595 }) {
5596 bail!(
5597 "git omitted an attribute result for a tracked path in {}",
5598 cwd.display()
5599 );
5600 }
5601 Ok(values)
5602}
5603
5604fn attribute_is_active(value: Option<&String>) -> bool {
5605 !matches!(
5606 value.map(String::as_str),
5607 None | Some("unspecified") | Some("unset")
5608 )
5609}
5610
5611fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
5612 attribute_is_active(values.get("filter"))
5613 || attribute_is_active(values.get("working-tree-encoding"))
5614}
5615
5616struct CheckoutConfig {
5625 autocrlf: Option<String>,
5626 eol: Option<String>,
5627 symlinks: Option<bool>,
5628}
5629
5630impl CheckoutConfig {
5631 fn read(cwd: &Path) -> Result<Self> {
5632 Ok(Self {
5633 autocrlf: git_config_value(cwd, "core.autocrlf")?,
5634 eol: git_config_value(cwd, "core.eol")?,
5635 symlinks: git_config_bool(cwd, "core.symlinks")?,
5636 })
5637 }
5638
5639 fn autocrlf_is_true(&self) -> bool {
5640 self.autocrlf.as_ref().is_some_and(|value| {
5641 matches!(
5642 value.to_ascii_lowercase().as_str(),
5643 "true" | "yes" | "on" | "1"
5644 )
5645 })
5646 }
5647
5648 fn eol_is(&self, wanted: &str) -> bool {
5649 self.eol
5650 .as_ref()
5651 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
5652 }
5653}
5654
5655fn path_has_ambiguous_transform(
5656 config: &CheckoutConfig,
5657 values: &BTreeMap<String, String>,
5658) -> Result<bool> {
5659 if path_has_external_transform(values)
5660 || attribute_is_active(values.get("ident"))
5661 || attribute_is_active(values.get("crlf"))
5662 {
5663 return Ok(true);
5664 }
5665 let text = values.get("text").map(String::as_str);
5666 let eol = values.get("eol").map(String::as_str);
5667 if text == Some("auto") {
5668 return Ok(true);
5669 }
5670 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
5671 || !matches!(
5672 eol,
5673 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
5674 )
5675 {
5676 return Ok(true);
5677 }
5678 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
5679 return Ok(config.autocrlf_is_true());
5680 }
5681 Ok(false)
5682}
5683
5684fn allows_expected_crlf(
5685 config: &CheckoutConfig,
5686 values: &BTreeMap<String, String>,
5687) -> Result<bool> {
5688 if path_has_external_transform(values)
5689 || attribute_is_active(values.get("ident"))
5690 || attribute_is_active(values.get("crlf"))
5691 {
5692 return Ok(false);
5693 }
5694 let text = values.get("text").map(String::as_str);
5695 let eol = values.get("eol").map(String::as_str);
5696 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
5697 return Ok(false);
5698 }
5699 if eol == Some("crlf") {
5700 return Ok(true);
5701 }
5702 if text != Some("set") {
5703 return Ok(false);
5704 }
5705 if let Some(autocrlf) = config.autocrlf.as_deref() {
5706 match autocrlf.to_ascii_lowercase().as_str() {
5707 "true" | "yes" | "on" | "1" => return Ok(true),
5708 "input" => return Ok(false),
5709 _ => {}
5710 }
5711 }
5712 if config.eol_is("crlf") {
5713 return Ok(true);
5714 }
5715 #[cfg(windows)]
5716 if config.eol.is_none() || config.eol_is("native") {
5717 return Ok(true);
5718 }
5719 Ok(false)
5720}
5721
5722fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
5723 let argv = git_without_automation_argv(&["config", "--get", key]);
5724 let output = proc::exec(
5725 &argv,
5726 &ExecOpts::new()
5727 .cwd(cwd)
5728 .timeout_secs(30)
5729 .check(false)
5730 .stop_descendants(true),
5731 )?;
5732 match output.code {
5733 0 => Ok(Some(output.stdout.trim().to_string())),
5734 1 => Ok(None),
5735 _ => bail!(
5736 "could not read Git configuration in {}: {}",
5737 cwd.display(),
5738 output.stderr.trim()
5739 ),
5740 }
5741}
5742
5743fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
5744 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
5745 let output = proc::exec(
5746 &argv,
5747 &ExecOpts::new()
5748 .cwd(cwd)
5749 .timeout_secs(30)
5750 .check(false)
5751 .stop_descendants(true),
5752 )?;
5753 match output.code {
5754 0 if output.stdout.trim() == "true" => Ok(Some(true)),
5755 0 if output.stdout.trim() == "false" => Ok(Some(false)),
5756 0 => bail!(
5757 "git returned an invalid boolean for {key} in {}",
5758 cwd.display()
5759 ),
5760 1 => Ok(None),
5761 _ => bail!(
5762 "could not read Git configuration in {}: {}",
5763 cwd.display(),
5764 output.stderr.trim()
5765 ),
5766 }
5767}
5768
5769#[cfg(unix)]
5770fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
5771 use std::os::unix::fs::PermissionsExt;
5772 if metadata.permissions().mode() & 0o111 == 0 {
5773 "100644".to_string()
5774 } else {
5775 "100755".to_string()
5776 }
5777}
5778
5779#[cfg(not(unix))]
5780fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
5781 "100644".to_string()
5782}
5783
5784fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
5785 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
5786 if !listed.is_empty() && !listed.ends_with(&[0]) {
5787 bail!(
5788 "git returned an unterminated tree listing for {}",
5789 cwd.display()
5790 );
5791 }
5792 let mut entries = Vec::new();
5793 for record in listed
5794 .split(|byte| *byte == 0)
5795 .filter(|record| !record.is_empty())
5796 {
5797 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5798 bail!("git returned a malformed tree record for {}", cwd.display());
5799 };
5800 let fields = record[..tab]
5801 .split(|byte| *byte == b' ')
5802 .collect::<Vec<_>>();
5803 if fields.len() != 3 {
5804 bail!("git returned a malformed tree header for {}", cwd.display());
5805 }
5806 let mode = std::str::from_utf8(fields[0])
5807 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
5808 .to_string();
5809 let oid = std::str::from_utf8(fields[2])
5810 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5811 .to_string();
5812 entries.push(IndexEntry {
5813 path: safe_git_path(&record[tab + 1..], "tree")?,
5814 mode,
5815 oid,
5816 });
5817 }
5818 Ok(entries)
5819}
5820
5821fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
5822 Ok(tree_entries(cwd, "HEAD")?
5823 .into_iter()
5824 .filter(|entry| entry.mode == "160000")
5825 .map(|entry| (entry.path, entry.oid))
5826 .collect())
5827}
5828
5829fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
5830 let head = head_gitlinks(cwd)?;
5831 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
5832 .into_iter()
5833 .map(|link| (link.path, link.oid))
5834 .collect();
5835 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
5836 paths.extend(index.keys().cloned());
5837 Ok(paths
5838 .into_iter()
5839 .filter(|path| head.get(path) != index.get(path))
5840 .collect())
5841}
5842
5843fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
5844 let path = parent.join(relative);
5845 let metadata = match std::fs::symlink_metadata(&path) {
5846 Ok(metadata) => metadata,
5847 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5848 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
5849 };
5850 if !metadata.is_dir() {
5851 bail!("the gitlink at {} is not a directory", path.display());
5852 }
5853 let canonical = std::fs::canonicalize(&path)
5854 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
5855 if canonical != path {
5856 bail!(
5857 "the gitlink at {} resolves through a symlink",
5858 path.display()
5859 );
5860 }
5861 if !path.join(".git").exists() {
5862 let empty = std::fs::read_dir(&path)
5863 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
5864 .next()
5865 .is_none();
5866 if empty {
5867 return Ok(None);
5868 }
5869 bail!(
5870 "the uninitialized gitlink at {} contains local files",
5871 path.display()
5872 );
5873 }
5874 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
5875 if inside.trim() != "true" {
5876 bail!("the gitlink at {} is not a worktree", path.display());
5877 }
5878 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
5879 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
5880 spar_err!(
5881 "could not resolve the gitlink top level at {}: {e}",
5882 path.display()
5883 )
5884 })?;
5885 if top != canonical {
5886 bail!(
5887 "the gitlink at {} belongs to a different worktree",
5888 path.display()
5889 );
5890 }
5891 Ok(Some(canonical))
5892}
5893
5894fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
5895 let root = std::fs::canonicalize(cwd)
5896 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5897 let mut allowed = BTreeSet::from([root.join(".git")]);
5898 let mut repositories = vec![root.clone()];
5899 let mut visited = BTreeSet::new();
5900 while let Some(repository) = repositories.pop() {
5901 let canonical = std::fs::canonicalize(&repository)
5902 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5903 if !visited.insert(canonical.clone()) {
5904 bail!("submodule recursion revisited {}", canonical.display());
5905 }
5906 for link in gitlinks(&canonical)? {
5907 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
5908 continue;
5909 };
5910 allowed.insert(submodule.join(".git"));
5911 repositories.push(submodule);
5912 }
5913 }
5914
5915 let scan_root = root.clone();
5916 let mut directories = vec![root];
5917 while let Some(directory) = directories.pop() {
5918 let entries = std::fs::read_dir(&directory)
5919 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5920 for entry in entries {
5921 let entry =
5922 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5923 let path = entry.path();
5924 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
5925 continue;
5926 }
5927 if entry.file_name() == OsStr::new(".git") {
5928 if !allowed.contains(&path) {
5929 return Ok(Some(path));
5930 }
5931 continue;
5932 }
5933 let kind = entry
5934 .file_type()
5935 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5936 if kind.is_dir() {
5937 directories.push(path);
5938 }
5939 }
5940 }
5941 Ok(None)
5942}
5943
5944pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
5945 if let Some(path) = unexpected_nested_git_entry(cwd)? {
5946 bail!(
5947 "the worktree contains an untracked Git entry at {}. It was kept because its \
5948 repository objects are not represented by the outer index.",
5949 path.display()
5950 );
5951 }
5952 let root = std::fs::canonicalize(cwd)
5953 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5954 let mut repositories = BTreeMap::new();
5955 let mut visited = BTreeSet::new();
5956 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
5957 Ok(GitState { repositories })
5958}
5959
5960fn collect_git_state(
5961 repository: &Path,
5962 prefix: &Path,
5963 visited: &mut BTreeSet<PathBuf>,
5964 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
5965) -> Result<()> {
5966 let canonical = std::fs::canonicalize(repository)
5967 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5968 if !visited.insert(canonical.clone()) {
5969 bail!("submodule recursion revisited {}", canonical.display());
5970 }
5971 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5972 let head = head.trim().to_string();
5973 if head.is_empty() {
5974 bail!("git returned an empty head for {}", repository.display());
5975 }
5976 let unsafe_index_flags = unsafe_index_flags(repository)?;
5977 let tracked = tracked_entries(repository)?;
5978 let gitlinks = gitlinks(repository)?;
5979 if repositories
5980 .insert(
5981 prefix.to_path_buf(),
5982 RepositoryState {
5983 head,
5984 unsafe_index_flags,
5985 tracked,
5986 gitlinks: gitlinks
5987 .iter()
5988 .map(|link| (link.path.clone(), link.oid.clone()))
5989 .collect(),
5990 },
5991 )
5992 .is_some()
5993 {
5994 bail!("Git state contains duplicate repository path {:?}", prefix);
5995 }
5996
5997 for link in gitlinks {
5998 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5999 continue;
6000 };
6001 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6002 }
6003 Ok(())
6004}
6005
6006fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6007 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6008 if !listed.is_empty() && !listed.ends_with(&[0]) {
6009 bail!(
6010 "git returned an unterminated index-flag listing for {}",
6011 cwd.display()
6012 );
6013 }
6014 let mut unsafe_records = Vec::new();
6015 for record in listed
6016 .split(|byte| *byte == 0)
6017 .filter(|record| !record.is_empty())
6018 {
6019 if record.len() < 3 || record[1] != b' ' {
6020 bail!(
6021 "git returned a malformed index-flag record for {}",
6022 cwd.display()
6023 );
6024 }
6025 if record[0] != b'H' {
6026 unsafe_records.extend_from_slice(record);
6027 unsafe_records.push(0);
6028 }
6029 }
6030 Ok(unsafe_records)
6031}
6032
6033pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6034 safe_git_state(cwd).map(|_| ())
6035}
6036
6037pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6038 let state = git_state(cwd)?;
6039 if let Some((path, _repository)) = state
6040 .repositories
6041 .iter()
6042 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6043 {
6044 let label = if path.as_os_str().is_empty() {
6045 cwd.to_path_buf()
6046 } else {
6047 cwd.join(path)
6048 };
6049 bail!(
6050 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6051 SPAR cannot prove the working files are unchanged, so it was kept.",
6052 label.display()
6053 );
6054 }
6055 Ok(state)
6056}
6057
6058fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6059 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6060 return Ok(true);
6061 }
6062 let mut visited = BTreeSet::new();
6063 repository_has_recoverable_work_inner(cwd, include_ignored, &mut visited)
6064}
6065
6066fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6067 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6068 let git_dir = PathBuf::from(git_dir.trim());
6069 let git_dir = if git_dir.is_absolute() {
6070 git_dir
6071 } else {
6072 cwd.join(git_dir)
6073 };
6074 let git_dir = std::fs::canonicalize(&git_dir)
6075 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6076 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6077 Ok(_) => return Ok(true),
6078 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6079 Err(error) => {
6080 return Err(spar_err!(
6081 "could not inspect per-worktree configuration in {}: {error}",
6082 git_dir.display()
6083 ))
6084 }
6085 }
6086
6087 let orig_head = git_dir.join("ORIG_HEAD");
6088 match std::fs::symlink_metadata(&orig_head) {
6089 Ok(metadata) if metadata.is_file() => {
6090 let oid = std::fs::read_to_string(&orig_head)
6091 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6092 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6093 return Ok(true);
6094 };
6095 if !commit_has_shared_ref(cwd, &commit)? {
6096 return Ok(true);
6097 }
6098 }
6099 Ok(_) => return Ok(true),
6100 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6101 Err(error) => {
6102 return Err(spar_err!(
6103 "could not inspect {}: {error}",
6104 orig_head.display()
6105 ))
6106 }
6107 }
6108
6109 let edit_message = git_dir.join("COMMIT_EDITMSG");
6110 match std::fs::symlink_metadata(&edit_message) {
6111 Ok(metadata) if metadata.is_file() => {
6112 let draft = std::fs::read(&edit_message)
6113 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6114 if draft != head_commit_message(cwd)? {
6115 return Ok(true);
6116 }
6117 }
6118 Ok(_) => return Ok(true),
6119 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6120 Err(error) => {
6121 return Err(spar_err!(
6122 "could not inspect {}: {error}",
6123 edit_message.display()
6124 ))
6125 }
6126 }
6127
6128 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6129 return Ok(true);
6130 }
6131
6132 let local_refs = run_git_bytes(
6133 cwd,
6134 &[
6135 "for-each-ref",
6136 "--format=%(refname)",
6137 "refs/worktree",
6138 "refs/bisect",
6139 "refs/rewritten",
6140 ],
6141 )?;
6142 if !local_refs.is_empty() {
6143 return Ok(true);
6144 }
6145
6146 for entry in std::fs::read_dir(&git_dir)
6147 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6148 {
6149 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6150 let known = matches!(
6151 entry.file_name().to_str(),
6152 Some(
6153 "HEAD"
6154 | "ORIG_HEAD"
6155 | "COMMIT_EDITMSG"
6156 | "commondir"
6157 | "gitdir"
6158 | "index"
6159 | "logs"
6160 | "refs"
6161 )
6162 );
6163 if !known {
6164 return Ok(true);
6165 }
6166 }
6167
6168 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6169 if !commit_has_shared_ref(cwd, head.trim())? {
6170 return Ok(true);
6171 }
6172 Ok(false)
6173}
6174
6175fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6176 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6177 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6178 bail!(
6179 "git returned a commit without a message separator in {}",
6180 cwd.display()
6181 );
6182 };
6183 Ok(commit[split + 2..].to_vec())
6184}
6185
6186fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6187 let metadata = match std::fs::symlink_metadata(logs) {
6188 Ok(metadata) => metadata,
6189 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6190 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6191 };
6192 if !metadata.is_dir() {
6193 return Ok(true);
6194 }
6195 let mut files = Vec::new();
6196 let mut directories = vec![logs.to_path_buf()];
6197 while let Some(directory) = directories.pop() {
6198 for entry in std::fs::read_dir(&directory)
6199 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6200 {
6201 let entry =
6202 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6203 let path = entry.path();
6204 let kind = entry
6205 .file_type()
6206 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6207 if kind.is_dir() {
6208 directories.push(path);
6209 } else if kind.is_file() {
6210 files.push(path);
6211 } else {
6212 return Ok(true);
6213 }
6214 }
6215 }
6216
6217 let mut commits = BTreeSet::new();
6218 for path in files {
6219 if !collect_reflog_commits(cwd, &path, &mut commits)? {
6220 return Ok(true);
6221 }
6222 }
6223 for commit in commits {
6224 if !commit_has_shared_ref(cwd, &commit)? {
6225 return Ok(true);
6226 }
6227 }
6228 Ok(false)
6229}
6230
6231fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6235 let common = common_git_dir(cwd)?;
6236 let reflog = common.join("logs").join(refname);
6237 let metadata = match std::fs::symlink_metadata(&reflog) {
6238 Ok(metadata) => metadata,
6239 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6240 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6241 };
6242 if !metadata.is_file() {
6243 return Ok(false);
6244 }
6245 let mut commits = BTreeSet::new();
6246 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6247 return Ok(false);
6248 }
6249 for commit in commits {
6250 if is_ancestor(cwd, &commit, durable_tip)?
6251 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6252 {
6253 continue;
6254 }
6255 return Ok(false);
6256 }
6257 Ok(true)
6258}
6259
6260fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6261 let file = std::fs::File::open(path)
6262 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6263 for line in std::io::BufReader::new(file).lines() {
6264 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6265 let mut fields = line.splitn(3, ' ');
6266 let Some(old) = fields.next() else {
6267 return Ok(false);
6268 };
6269 let Some(new) = fields.next() else {
6270 return Ok(false);
6271 };
6272 if fields.next().is_none() {
6273 return Ok(false);
6274 }
6275 for oid in [old, new] {
6276 if oid.bytes().all(|byte| byte == b'0') {
6277 continue;
6278 }
6279 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6280 return Ok(false);
6281 };
6282 commits.insert(commit);
6283 }
6284 }
6285 Ok(true)
6286}
6287
6288fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6289 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6290 let path = PathBuf::from(raw.trim());
6291 let path = if path.is_absolute() {
6292 path
6293 } else {
6294 cwd.join(path)
6295 };
6296 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6297}
6298
6299fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6300 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6301 let output = proc::exec(
6302 &argv,
6303 &ExecOpts::new()
6304 .cwd(cwd)
6305 .timeout_secs(30)
6306 .check(false)
6307 .stop_descendants(true),
6308 )?;
6309 match output.code {
6310 0 => Ok(true),
6311 1 => Ok(false),
6312 _ => bail!("{}", proc::failure_message(&argv, &output)),
6313 }
6314}
6315
6316fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6317 let commit = format!("{oid}^{{commit}}");
6318 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6319 let output = proc::exec(
6320 &argv,
6321 &ExecOpts::new()
6322 .cwd(cwd)
6323 .timeout_secs(30)
6324 .check(false)
6325 .stop_descendants(true),
6326 )?;
6327 if output.code != 0 {
6328 return Ok(None);
6329 }
6330 let oid = output.stdout.trim();
6331 if oid.is_empty() {
6332 return Ok(None);
6333 }
6334 Ok(Some(oid.to_string()))
6335}
6336
6337fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6338 commit_has_shared_ref_except(cwd, oid, None)
6339}
6340
6341fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6342 let contains = format!("--contains={oid}");
6343 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6344 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6345 !record.is_empty()
6346 && !record.starts_with(b"refs/worktree/")
6347 && !record.starts_with(b"refs/bisect/")
6348 && !record.starts_with(b"refs/rewritten/")
6349 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6350 }))
6351}
6352
6353fn has_untracked_work_worth_keeping(cwd: &Path) -> Result<bool> {
6368 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6369 if !ordinary.is_empty() {
6370 return Ok(true);
6371 }
6372 let listed = untracked_listing(
6373 cwd,
6374 &[
6375 "ls-files",
6376 "--others",
6377 "--ignored",
6378 "--exclude-standard",
6379 "-z",
6380 ],
6381 )?;
6382 for raw in listed {
6383 let (path, nested) = untracked_record(&raw, "ignored")?;
6384 if nested || !is_generated_artifact(&path) {
6385 return Ok(true);
6386 }
6387 }
6388 Ok(false)
6389}
6390
6391fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6392 let listed = run_git_bytes(cwd, args)?;
6393 if !listed.is_empty() && !listed.ends_with(&[0]) {
6394 bail!(
6395 "git returned an unterminated untracked-file list for {}",
6396 cwd.display()
6397 );
6398 }
6399 Ok(listed
6400 .split(|byte| *byte == 0)
6401 .filter(|raw| !raw.is_empty())
6402 .map(|raw| raw.to_vec())
6403 .collect())
6404}
6405
6406fn repository_has_recoverable_work_inner(
6407 cwd: &Path,
6408 include_ignored: bool,
6409 visited: &mut BTreeSet<PathBuf>,
6410) -> Result<bool> {
6411 let canonical = std::fs::canonicalize(cwd)
6412 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6413 if !visited.insert(canonical.clone()) {
6414 bail!("submodule recursion revisited {}", canonical.display());
6415 }
6416 if include_ignored && has_untracked_work_worth_keeping(cwd)? {
6417 return Ok(true);
6418 }
6419 if !unsafe_index_flags(cwd)?.is_empty() {
6420 return Ok(true);
6421 }
6422 if attributes_may_be_modified(cwd)? {
6423 return Ok(true);
6424 }
6425 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6426 return Ok(true);
6427 }
6428 if include_ignored {
6429 let index = index_entries(cwd)?
6430 .into_iter()
6431 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6432 .collect::<BTreeMap<_, _>>();
6433 let head = tree_entries(cwd, "HEAD")?
6434 .into_iter()
6435 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6436 .collect::<BTreeMap<_, _>>();
6437 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6438 return Ok(true);
6439 }
6440 let tracked = tracked_entries(cwd)?;
6441 let effective = check_attributes(cwd, tracked.keys().cloned())?;
6442 let config = CheckoutConfig::read(cwd)?;
6443 for (path, entry) in tracked {
6444 let Some(worktree) = entry.worktree else {
6445 return Ok(true);
6446 };
6447 let attributes = effective.get(&path).ok_or_else(|| {
6448 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6449 })?;
6450 if path_has_ambiguous_transform(&config, attributes)? {
6451 return Ok(true);
6452 }
6453 let symlink_file = entry.index_mode == "120000"
6454 && worktree.mode == "100644"
6455 && worktree.raw_oid == entry.index_oid
6456 && config.symlinks == Some(false);
6457 if worktree.mode != entry.index_mode && !symlink_file {
6458 return Ok(true);
6459 }
6460 if entry.index_mode == "120000" {
6461 if worktree.raw_oid != entry.index_oid {
6462 return Ok(true);
6463 }
6464 continue;
6465 }
6466 #[cfg(unix)]
6467 {
6468 let expected = if entry.index_mode == "100755" {
6469 0o755
6470 } else {
6471 0o644
6472 };
6473 if worktree.permissions != expected {
6474 return Ok(true);
6475 }
6476 }
6477 if allows_expected_crlf(&config, attributes)? {
6478 let (normalized, every_lf_was_crlf) =
6479 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6480 if !every_lf_was_crlf || normalized != entry.index_oid {
6481 return Ok(true);
6482 }
6483 } else if worktree.raw_oid != entry.index_oid {
6484 return Ok(true);
6485 }
6486 }
6487 } else {
6488 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6489 if !run_git_bytes(cwd, &args)?.is_empty() {
6490 return Ok(true);
6491 }
6492 }
6493 for link in gitlinks(cwd)? {
6494 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6495 continue;
6496 };
6497 if include_ignored {
6502 return Ok(true);
6503 }
6504 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6505 if head.trim() != link.oid {
6506 return Ok(true);
6507 }
6508 if repository_has_recoverable_work_inner(&submodule, include_ignored, visited)? {
6509 return Ok(true);
6510 }
6511 }
6512 Ok(false)
6513}
6514
6515pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6516 repository_has_recoverable_work(cwd, false)
6517}
6518
6519fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6520 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6521 Ok(!run_git_bytes(cwd, &args)?.is_empty())
6522}
6523
6524#[cfg(unix)]
6525fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6526 use std::os::unix::ffi::OsStringExt;
6527 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6528}
6529
6530#[cfg(not(unix))]
6531fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6532 String::from_utf8(raw.to_vec())
6533 .map(PathBuf::from)
6534 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6535}
6536
6537fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
6546 let metadata = std::fs::symlink_metadata(path).map_err(|e| {
6547 spar_err!(
6548 "could not inspect the nested repository at {}: {e}",
6549 path.display()
6550 )
6551 })?;
6552 if !metadata.is_dir() {
6553 bail!(
6554 "git reported {} as a nested repository, but it is not a directory",
6555 path.display()
6556 );
6557 }
6558 if std::fs::symlink_metadata(path.join(".git")).is_err() {
6559 bail!(
6560 "git reported {} as a nested repository, but it has no Git entry",
6561 path.display()
6562 );
6563 }
6564 #[cfg(unix)]
6565 {
6566 use std::os::unix::fs::MetadataExt;
6567 Ok(UntrackedFile {
6568 kind: 3,
6569 len: 0,
6570 modified: None,
6571 created: metadata.created().ok(),
6572 readonly: metadata.permissions().readonly(),
6573 symlink_target: None,
6574 device: metadata.dev(),
6575 inode: metadata.ino(),
6576 mode: metadata.mode(),
6577 change_seconds: 0,
6578 change_nanoseconds: 0,
6579 })
6580 }
6581 #[cfg(not(unix))]
6582 {
6583 Ok(UntrackedFile {
6584 kind: 3,
6585 len: 0,
6586 modified: None,
6587 created: metadata.created().ok(),
6588 readonly: metadata.permissions().readonly(),
6589 symlink_target: None,
6590 })
6591 }
6592}
6593
6594fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
6595 let metadata = std::fs::symlink_metadata(path)
6596 .map_err(|e| spar_err!("could not inspect untracked file {}: {e}", path.display()))?;
6597 let kind = if metadata.file_type().is_symlink() {
6598 2
6599 } else if metadata.is_file() {
6600 1
6601 } else {
6602 bail!(
6603 "untracked path {} is not a regular file or symlink",
6604 path.display()
6605 );
6606 };
6607 let symlink_target = if kind == 2 {
6608 let target = std::fs::read_link(path)
6609 .map_err(|e| spar_err!("could not read untracked symlink {}: {e}", path.display()))?;
6610 Some(os_str_bytes(target.as_os_str())?)
6611 } else {
6612 None
6613 };
6614 #[cfg(unix)]
6615 {
6616 use std::os::unix::fs::MetadataExt;
6617 Ok(UntrackedFile {
6618 kind,
6619 len: metadata.len(),
6620 modified: metadata.modified().ok(),
6621 created: metadata.created().ok(),
6622 readonly: metadata.permissions().readonly(),
6623 symlink_target,
6624 device: metadata.dev(),
6625 inode: metadata.ino(),
6626 mode: metadata.mode(),
6627 change_seconds: metadata.ctime(),
6628 change_nanoseconds: metadata.ctime_nsec(),
6629 })
6630 }
6631 #[cfg(not(unix))]
6632 {
6633 Ok(UntrackedFile {
6634 kind,
6635 len: metadata.len(),
6636 modified: metadata.modified().ok(),
6637 created: metadata.created().ok(),
6638 readonly: metadata.permissions().readonly(),
6639 symlink_target,
6640 })
6641 }
6642}
6643
6644#[cfg(unix)]
6645fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6646 use std::os::unix::ffi::OsStrExt;
6647 Ok(value.as_bytes().to_vec())
6648}
6649
6650#[cfg(not(unix))]
6651fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6652 value
6653 .to_str()
6654 .map(|value| value.as_bytes().to_vec())
6655 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
6656}
6657
6658#[cfg(unix)]
6659fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6660 use std::os::unix::fs::MetadataExt;
6661 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
6662}
6663
6664#[cfg(not(unix))]
6665fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6666 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
6667}
6668
6669#[derive(Debug, Clone, serde::Serialize, Deserialize)]
6670pub struct BranchRecord {
6671 pub kind: String,
6672 pub number: i64,
6673}
6674
6675pub fn review_ref(number: i64) -> String {
6678 format!("refs/spar/pr-{number}")
6679}
6680
6681pub fn is_finished(state: &str) -> bool {
6682 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
6683}
6684
6685pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
6692 if let Some(parent) = path.parent() {
6693 std::fs::create_dir_all(parent)
6694 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6695 }
6696 let tmp = path.with_extension(format!(
6699 "{}.tmp",
6700 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
6701 ));
6702 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
6703 std::fs::rename(&tmp, path)
6704 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
6705 Ok(())
6706}
6707
6708pub fn has_heading(text: &str, title: &str) -> bool {
6715 let wanted = title.trim().to_lowercase();
6716 if wanted.is_empty() {
6717 return false;
6718 }
6719 text.lines()
6720 .filter_map(|line| line.trim().strip_prefix("## "))
6721 .any(|heading| heading.trim().to_lowercase() == wanted)
6722}
6723
6724pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
6727 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
6728}
6729
6730#[derive(Deserialize)]
6732#[serde(rename_all = "camelCase")]
6733pub struct LinkedPrRow {
6734 number: i64,
6735 #[serde(default)]
6736 url: String,
6737 #[serde(default)]
6738 title: String,
6739 #[serde(default)]
6740 closing_issues_references: Vec<IssueRef>,
6741}
6742
6743pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
6750 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
6751}
6752
6753pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
6755 rows.iter()
6756 .find(|row| {
6757 row.closing_issues_references
6758 .iter()
6759 .any(|linked| linked.number == issue)
6760 })
6761 .map(|row| PrRef {
6762 number: row.number,
6763 url: row.url.clone(),
6764 title: row.title.clone(),
6765 })
6766}
6767
6768pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
6770 pick_linked_pr(&linked_pr_rows(json)?, issue)
6771}
6772
6773fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
6780 if text.trim().is_empty() {
6781 return Err(spar_err!("GitHub returned no comment data"));
6782 }
6783 let mut out = Vec::new();
6784 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6785 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
6786 Value::Array(items) => out.extend(items),
6787 _ => return Err(spar_err!("unexpected non-array comment page")),
6788 }
6789 }
6790 Ok(out)
6791}
6792
6793pub fn parse_comment_pages(text: &str) -> Vec<Value> {
6794 let mut out = Vec::new();
6795 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6796 match value {
6797 Ok(Value::Array(items)) => out.extend(items),
6798 Ok(other) => out.push(other),
6799 Err(_) => break,
6800 }
6801 }
6802 out
6803}
6804
6805pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
6813 body.trim_start().starts_with(STATE_MARKER)
6814 && !viewer.trim().is_empty()
6815 && author.trim().eq_ignore_ascii_case(viewer.trim())
6816 && parse_state_comment(body).is_some()
6817}
6818
6819pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
6822 let marker = body.find(STATE_MARKER)?;
6823 let start = body[marker..].find('{')? + marker;
6824 let end = body.rfind('}')?;
6825 if end <= start {
6826 return None;
6827 }
6828 match serde_json::from_str(&body[start..=end]) {
6829 Ok(state) => Some(state),
6830 Err(_) => {
6831 logdim!("found a spar state comment but could not parse it");
6832 None
6833 }
6834 }
6835}
6836
6837fn choose_state_for_head(
6838 candidates: Vec<PersistedState>,
6839 actual_head: &str,
6840) -> Option<PersistedState> {
6841 let matching: Vec<PersistedState> = candidates
6842 .iter()
6843 .filter(|state| state.pr_head == actual_head)
6844 .cloned()
6845 .collect();
6846 if !matching.is_empty() {
6847 return newest_state(matching);
6848 }
6849 newest_state(candidates)
6850}
6851
6852fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
6853 candidates.into_iter().reduce(|best, candidate| {
6854 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
6855 candidate
6856 } else {
6857 best
6862 }
6863 })
6864}
6865
6866pub fn self_binary() -> Result<PathBuf> {
6872 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
6873 let path = PathBuf::from(path);
6874 if proc::is_executable(&path) {
6875 return Ok(path);
6876 }
6877 bail!(
6878 "SPAR_SELF_BIN is set to {}, which is not executable",
6879 path.display()
6880 );
6881 }
6882 std::env::current_exe()
6883 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
6884}
6885
6886fn bool_env(value: bool) -> &'static str {
6887 if value {
6888 "1"
6889 } else {
6890 "0"
6891 }
6892}
6893
6894pub fn sh_quote(text: &str) -> String {
6897 format!("'{}'", text.replace('\'', r"'\''"))
6898}
6899
6900pub fn style_from_env() -> Style {
6903 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
6904 Style {
6905 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
6906 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
6907 ..Style::permissive()
6908 }
6909}
6910
6911#[cfg(test)]
6912mod tests {
6913 use super::*;
6914 use crate::config::StateStore;
6915 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
6916 use std::process::Command;
6917
6918 fn repo_for_titles() -> Repo {
6919 Repo {
6920 root: PathBuf::from("/nonexistent"),
6921 style: Style::default(),
6922 branch_prefix: String::new(),
6923 state_store: StateStore::Local,
6924 followups: crate::config::Followups::Issues,
6925 drafts: Drafts::Never,
6926 viewer: OnceLock::new(),
6927 checkpoints: Mutex::new(BTreeMap::new()),
6928 writes: WriteStats::default(),
6929 }
6930 }
6931
6932 #[test]
6933 fn write_results_accumulate_for_the_run() {
6934 let repo = repo_for_titles();
6935
6936 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
6937 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
6938
6939 assert_eq!(
6940 WriteSummary {
6941 attempted: 2,
6942 failed: 1,
6943 },
6944 repo.write_summary()
6945 );
6946 }
6947
6948 #[test]
6949 fn only_failed_write_preflights_join_the_summary() {
6950 let repo = repo_for_titles();
6951
6952 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
6953 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
6954
6955 assert_eq!(
6956 WriteSummary {
6957 attempted: 1,
6958 failed: 1,
6959 },
6960 repo.write_summary()
6961 );
6962 }
6963
6964 #[test]
6965 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
6966 let repo = repo_for_titles();
6967
6968 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
6969 assert_eq!(
6970 WriteSummary {
6971 attempted: 1,
6972 failed: 1,
6973 },
6974 repo.write_summary()
6975 );
6976 }
6977
6978 #[test]
6979 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
6980 let mut repo = repo_for_titles();
6981 repo.followups = Followups::Local;
6982
6983 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
6984 assert_eq!(WriteSummary::default(), repo.write_summary());
6985 }
6986
6987 #[test]
6988 fn a_failed_remote_state_read_stops_before_state_mutation() {
6989 let root = std::env::temp_dir().join(format!(
6990 "spar-state-preflight-{}-{}",
6991 std::process::id(),
6992 std::time::SystemTime::now()
6993 .duration_since(std::time::UNIX_EPOCH)
6994 .unwrap()
6995 .as_nanos()
6996 ));
6997 std::fs::create_dir_all(&root).unwrap();
6998 let _fixture = ReviewFixture { root: root.clone() };
6999 let mut repo = repo_for_titles();
7000 repo.root = root;
7001 repo.state_store = StateStore::Both;
7002 let state = PersistedState {
7003 version: 1,
7004 checkpoint: 4,
7005 round: 2,
7006 next_actor: "a".into(),
7007 status: Status::Pending,
7008 pr_head: "abc123".into(),
7009 ledger: Ledger::new(),
7010 filed: Vec::new(),
7011 open_findings: Vec::new(),
7012 disputes: Vec::new(),
7013 noted: Vec::new(),
7014 };
7015
7016 let error = repo
7017 .write_state_after_remote_read(
7018 7,
7019 &state,
7020 Err(crate::error::SparError::new("state comments unavailable")),
7021 )
7022 .unwrap_err();
7023
7024 assert!(error.to_string().contains("state comments unavailable"));
7025 assert!(!repo.state_path(7).exists());
7026 assert_eq!(0, repo.remembered_checkpoint(7));
7027 assert_eq!(
7028 WriteSummary {
7029 attempted: 1,
7030 failed: 1,
7031 },
7032 repo.write_summary()
7033 );
7034 }
7035
7036 #[test]
7037 fn only_known_build_and_cache_directories_are_generated_artifacts() {
7038 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7039 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7040 assert!(is_generated_artifact(Path::new(
7041 "package/node_modules/dependency/file.js"
7042 )));
7043 assert!(!is_generated_artifact(Path::new(
7044 "distribution/required-package.js"
7045 )));
7046 assert!(!is_generated_artifact(Path::new(
7047 "generated/required-fixture.txt"
7048 )));
7049 assert!(!is_generated_artifact(Path::new("local.env")));
7050 }
7051
7052 struct ReviewFixture {
7053 root: PathBuf,
7054 }
7055
7056 impl Drop for ReviewFixture {
7057 fn drop(&mut self) {
7058 let _ = std::fs::remove_dir_all(&self.root);
7059 }
7060 }
7061
7062 fn test_git(cwd: &Path, args: &[&str]) -> String {
7063 let output = Command::new("git")
7064 .args(args)
7065 .current_dir(cwd)
7066 .output()
7067 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7068 assert!(
7069 output.status.success(),
7070 "git {args:?} failed: {}",
7071 String::from_utf8_lossy(&output.stderr)
7072 );
7073 String::from_utf8_lossy(&output.stdout).into_owned()
7074 }
7075
7076 fn review_fixture(
7077 tag: &str,
7078 number: i64,
7079 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7080 use std::sync::atomic::{AtomicU32, Ordering};
7081 static NEXT: AtomicU32 = AtomicU32::new(0);
7082 let id = NEXT.fetch_add(1, Ordering::Relaxed);
7083 let root =
7084 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7085 let origin = root.join("origin.git");
7086 let work = root.join("work");
7087 std::fs::create_dir_all(&origin).unwrap();
7088 std::fs::create_dir_all(&work).unwrap();
7089 test_git(&origin, &["init", "--bare", "-b", "main"]);
7090 test_git(&work, &["init", "-b", "main"]);
7091 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7092 test_git(&work, &["config", "user.name", "spar test"]);
7093 test_git(&work, &["config", "commit.gpgsign", "false"]);
7094 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7095 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7096 std::fs::write(work.join("README.md"), "seed\n").unwrap();
7097 std::fs::write(work.join("data.txt"), "old\n").unwrap();
7098 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7099 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7100 test_git(&work, &["add", "."]);
7101 test_git(&work, &["commit", "-m", "seed"]);
7102 test_git(
7103 &work,
7104 &["remote", "add", "origin", origin.to_str().unwrap()],
7105 );
7106 test_git(&work, &["push", "-u", "origin", "main"]);
7107 test_git(
7108 &work,
7109 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7110 );
7111 let cfg = crate::config::parse(
7112 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7113 )
7114 .unwrap();
7115 let repo = Repo::open(&work, &cfg).unwrap();
7116 let path = repo.worktree_for_pr_head(number).unwrap();
7117 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7118 (ReviewFixture { root }, repo, path, checkpoint)
7119 }
7120
7121 #[test]
7122 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7123 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7124
7125 repo.release_review_worktree_checked(901, &checkpoint)
7126 .unwrap();
7127
7128 assert!(!path.exists());
7129 }
7130
7131 #[test]
7132 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7133 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7134 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7135 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7136 test_git(&path, &["add", "recovery.txt"]);
7137 test_git(&path, &["commit", "-m", "recovery commit"]);
7138 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7139 test_git(&path, &["reset", "--hard", "main"]);
7140
7141 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7142 test_git(
7143 &path,
7144 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7145 );
7146 }
7147
7148 #[test]
7149 fn a_review_ref_reflog_only_commit_prevents_deletion() {
7150 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7151 let local_ref = review_ref(921);
7152 let original = test_git(&path, &["rev-parse", &local_ref]);
7153 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7154 let recovery = test_git(
7155 &path,
7156 &[
7157 "commit-tree",
7158 tree.trim(),
7159 "-p",
7160 original.trim(),
7161 "-m",
7162 "review ref recovery",
7163 ],
7164 );
7165 test_git(
7166 &path,
7167 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7168 );
7169 test_git(
7170 &path,
7171 &["update-ref", &local_ref, original.trim(), recovery.trim()],
7172 );
7173
7174 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7175 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7176 test_git(
7177 &path,
7178 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7179 );
7180 }
7181
7182 #[test]
7183 fn an_unpublished_commit_message_draft_is_recoverable() {
7184 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7185 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7186 let git_dir = if raw.is_absolute() {
7187 raw
7188 } else {
7189 path.join(raw)
7190 };
7191 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7192
7193 assert!(repository_has_recoverable_work(&path, true).unwrap());
7194 assert_eq!(
7195 "unique recovery draft\n",
7196 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7197 );
7198 }
7199
7200 #[test]
7201 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7202 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7203 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7204
7205 let error = repo
7206 .release_review_worktree_checked(902, &checkpoint)
7207 .unwrap_err();
7208
7209 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7210 assert!(error.to_string().contains("kept for recovery"), "{error}");
7211 assert_eq!(
7212 "recover me\n",
7213 std::fs::read_to_string(path.join("README.md")).unwrap()
7214 );
7215 repo.release_review_worktree(902);
7216 }
7217
7218 #[test]
7219 fn a_review_commit_is_retained_after_a_checked_read() {
7220 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7221 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7222 test_git(&path, &["add", "review-note.txt"]);
7223 test_git(&path, &["commit", "-m", "local review recovery"]);
7224 let head = test_git(&path, &["rev-parse", "HEAD"]);
7225
7226 let error = repo
7227 .release_review_worktree_checked(903, &checkpoint)
7228 .unwrap_err();
7229
7230 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7231 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7232 assert_eq!(
7233 "recover me\n",
7234 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7235 );
7236 repo.release_review_worktree(903);
7237 }
7238
7239 #[test]
7240 fn an_ignored_review_file_is_retained_after_a_checked_read() {
7241 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7242 std::fs::create_dir_all(path.join("generated")).unwrap();
7243 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7244
7245 let error = repo
7246 .release_review_worktree_checked(904, &checkpoint)
7247 .unwrap_err();
7248
7249 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7250 assert_eq!(
7251 "recover me\n",
7252 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7253 );
7254 repo.release_review_worktree(904);
7255 }
7256
7257 #[test]
7258 fn a_preexisting_ignored_review_file_change_is_retained() {
7259 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7260 std::fs::create_dir_all(path.join("generated")).unwrap();
7261 let ignored = path.join("generated/recovery.txt");
7262 std::fs::write(&ignored, "before\n").unwrap();
7263 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7264 std::fs::write(&ignored, "after!\n").unwrap();
7265
7266 let error = repo
7267 .release_review_worktree_checked(905, &checkpoint)
7268 .unwrap_err();
7269
7270 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7271 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7272 repo.release_review_worktree(905);
7273 }
7274
7275 #[test]
7276 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7277 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7278 std::fs::create_dir_all(path.join("generated")).unwrap();
7279 let ignored = path.join("generated/recovery.txt");
7280 std::fs::write(&ignored, "keep me\n").unwrap();
7281 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7282
7283 let error = repo
7284 .release_review_worktree_checked(906, &checkpoint)
7285 .unwrap_err();
7286
7287 assert!(error.to_string().contains("recoverable"), "{error}");
7288 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7289 }
7290
7291 #[test]
7292 fn overwriting_a_preexisting_untracked_file_is_detected() {
7293 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7294 let untracked = path.join("notes.txt");
7295 std::fs::write(&untracked, "before\n").unwrap();
7296 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7297 std::fs::write(&untracked, "after!\n").unwrap();
7298
7299 let error = repo
7300 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7301 .unwrap_err();
7302
7303 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7304 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7305 }
7306
7307 #[test]
7308 fn an_assume_unchanged_edit_is_detected() {
7309 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7310 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7311 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7312
7313 let error = repo
7314 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7315 .unwrap_err();
7316
7317 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7318 assert_eq!(
7319 "hidden\n",
7320 std::fs::read_to_string(path.join("README.md")).unwrap()
7321 );
7322 }
7323
7324 #[test]
7325 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7326 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7327 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7328 test_git(&path, &["add", "README.md"]);
7329 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7330
7331 let error = repo
7332 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7333 .unwrap_err();
7334
7335 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7336 assert_eq!(
7337 b"seed\r\n",
7338 std::fs::read(path.join("README.md")).unwrap().as_slice()
7339 );
7340 }
7341
7342 #[cfg(unix)]
7343 #[test]
7344 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7345 use std::os::unix::fs::PermissionsExt;
7346
7347 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7348 test_git(&path, &["config", "core.filemode", "false"]);
7349 let readme = path.join("README.md");
7350 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7351 permissions.set_mode(0o755);
7352 std::fs::set_permissions(&readme, permissions).unwrap();
7353 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7354
7355 let error = repo
7356 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7357 .unwrap_err();
7358
7359 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7360 assert_eq!(
7361 0o755,
7362 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7363 );
7364 }
7365
7366 #[test]
7367 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7368 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7369 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7370 test_git(&path, &["add", ".gitattributes"]);
7371 test_git(&path, &["commit", "-m", "select data filter"]);
7372 let baseline = repo.worktree_baseline(&path).unwrap();
7373 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7374
7375 assert!(repo
7376 .commit_pending_changes(&path, &baseline, "change data", "change data")
7377 .unwrap());
7378 let error = repo
7379 .refuse_unrepresented_tracked_changes(&path, &baseline)
7380 .unwrap_err();
7381
7382 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7383 assert_eq!(
7384 "secret: recover me\nnew\n",
7385 std::fs::read_to_string(path.join("data.txt")).unwrap()
7386 );
7387 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7388 }
7389
7390 #[test]
7391 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7392 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7393 std::fs::create_dir_all(path.join("target")).unwrap();
7394 let untracked = path.join("target/user.yaml");
7395 std::fs::write(&untracked, "user data\n").unwrap();
7396 let baseline = repo.worktree_baseline(&path).unwrap();
7397 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7398
7399 assert!(repo
7400 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7401 .unwrap());
7402
7403 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
7404 assert_eq!(
7405 "?? target/user.yaml\n",
7406 test_git(&path, &["status", "--short", "--untracked-files=all"])
7407 );
7408 assert!(test_git(
7409 &path,
7410 &[
7411 "ls-tree",
7412 "-r",
7413 "--name-only",
7414 "HEAD",
7415 "--",
7416 "target/user.yaml"
7417 ]
7418 )
7419 .is_empty());
7420 }
7421
7422 #[test]
7423 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
7424 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
7425 std::fs::create_dir_all(path.join("target")).unwrap();
7426 let untracked = path.join("target/user.yaml");
7427 std::fs::write(&untracked, "before\n").unwrap();
7428 let baseline = repo.worktree_baseline(&path).unwrap();
7429 let before = test_git(&path, &["rev-parse", "HEAD"]);
7430 std::fs::write(&untracked, "after\n").unwrap();
7431 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7432
7433 let error = repo
7434 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7435 .unwrap_err();
7436
7437 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7438 assert!(error.to_string().contains("target/user.yaml"), "{error}");
7439 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7440 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
7441 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
7442 }
7443
7444 #[test]
7445 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
7446 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
7447 let baseline = repo.worktree_baseline(&path).unwrap();
7448 std::fs::create_dir_all(path.join("target")).unwrap();
7449 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
7450
7451 assert!(repo
7452 .commit_pending_changes(&path, &baseline, "add file", "add file")
7453 .unwrap());
7454
7455 assert_eq!(
7456 "new file\n",
7457 test_git(&path, &["show", "HEAD:target/new.txt"])
7458 );
7459 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7460 }
7461
7462 #[test]
7463 fn deleting_existing_ignored_work_stops_a_managed_commit() {
7464 let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7465 std::fs::create_dir_all(path.join("generated")).unwrap();
7466 let ignored = path.join("generated/keep.txt");
7467 std::fs::write(&ignored, "user data\n").unwrap();
7468 let baseline = repo.worktree_baseline(&path).unwrap();
7469 let before = test_git(&path, &["rev-parse", "HEAD"]);
7470 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7471 std::fs::remove_file(&ignored).unwrap();
7472
7473 let error = repo
7474 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7475 .unwrap_err();
7476
7477 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7478 assert!(error.to_string().contains("existing untracked"), "{error}");
7479 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7480 assert_eq!(
7481 "tracked change\n",
7482 std::fs::read_to_string(path.join("README.md")).unwrap()
7483 );
7484 }
7485
7486 #[test]
7487 fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
7488 let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
7489 let baseline = repo.worktree_baseline(&path).unwrap();
7490 let before = test_git(&path, &["rev-parse", "HEAD"]);
7491 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7492 std::fs::create_dir_all(path.join("generated")).unwrap();
7493 let ignored = path.join("generated/recovery.txt");
7494 std::fs::write(&ignored, "keep me\n").unwrap();
7495
7496 let error = repo
7497 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7498 .unwrap_err();
7499
7500 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7501 assert!(error.to_string().contains("recovery.txt"), "{error}");
7502 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7503 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7504 assert!(test_git(&path, &["status", "--porcelain"])
7505 .lines()
7506 .any(|line| line == "M README.md"));
7507 }
7508
7509 #[test]
7510 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
7511 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
7512 test_git(&path, &["config", "core.autocrlf", "true"]);
7513 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7514 assert_eq!(
7515 test_git(&path, &["hash-object", "README.md"]).trim(),
7516 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7517 );
7518
7519 assert!(repository_has_recoverable_work(&path, true).unwrap());
7520 assert_eq!(
7521 "seed\n",
7522 std::fs::read_to_string(path.join("README.md")).unwrap()
7523 );
7524 }
7525
7526 #[test]
7527 fn autocrlf_input_overrides_a_crlf_core_eol() {
7528 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
7529 test_git(&path, &["config", "core.autocrlf", "input"]);
7530 test_git(&path, &["config", "core.eol", "crlf"]);
7531 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7532 assert_eq!(
7533 test_git(&path, &["hash-object", "README.md"]).trim(),
7534 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7535 );
7536
7537 assert!(repository_has_recoverable_work(&path, true).unwrap());
7538 assert_eq!(
7539 b"seed\r\n",
7540 std::fs::read(path.join("README.md")).unwrap().as_slice()
7541 );
7542 }
7543
7544 #[cfg(unix)]
7545 #[test]
7546 fn a_non_executable_permission_change_is_recoverable() {
7547 use std::os::unix::fs::PermissionsExt;
7548
7549 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
7550 let readme = path.join("README.md");
7551 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7552 permissions.set_mode(0o600);
7553 std::fs::set_permissions(&readme, permissions).unwrap();
7554 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7555
7556 let error = repo
7557 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7558 .unwrap_err();
7559
7560 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7561 assert!(repository_has_recoverable_work(&path, true).unwrap());
7562 assert_eq!(
7563 0o600,
7564 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7565 );
7566 }
7567
7568 #[cfg(unix)]
7569 #[test]
7570 fn a_managed_commit_skips_signing_and_hooks() {
7571 use std::os::unix::fs::PermissionsExt;
7572
7573 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
7574 let common = common_git_dir(&path).unwrap();
7575 let hook = common.join("hooks/pre-commit");
7576 let marker = fixture.root.join("hook-ran");
7577 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
7578 std::fs::write(
7579 &hook,
7580 format!(
7581 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
7582 sh_quote(marker.to_str().unwrap())
7583 ),
7584 )
7585 .unwrap();
7586 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
7587 permissions.set_mode(0o755);
7588 std::fs::set_permissions(&hook, permissions).unwrap();
7589 test_git(&path, &["config", "commit.gpgsign", "true"]);
7590 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
7591 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
7592 test_git(&path, &["add", "managed.txt"]);
7593
7594 repo.commit_staged_changes(&path, "record managed change")
7595 .unwrap();
7596
7597 assert!(!marker.exists());
7598 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
7599 }
7600
7601 #[test]
7602 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
7603 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
7604 std::fs::write(
7605 path.join(".gitattributes"),
7606 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
7607 )
7608 .unwrap();
7609 test_git(&path, &["add", ".gitattributes"]);
7610 test_git(&path, &["commit", "-m", "select automatic text"]);
7611 test_git(&path, &["config", "core.autocrlf", "true"]);
7612
7613 assert!(repository_has_recoverable_work(&path, true).unwrap());
7614 }
7615
7616 #[test]
7617 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
7618 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
7619 std::fs::write(
7620 path.join(".gitattributes"),
7621 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
7622 )
7623 .unwrap();
7624 test_git(&path, &["add", ".gitattributes"]);
7625 test_git(&path, &["commit", "-m", "select ident expansion"]);
7626 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7627
7628 assert!(repository_has_recoverable_work(&path, true).unwrap());
7629 }
7630
7631 fn exclude_paths(repo: &Repo, lines: &[&str]) {
7634 use std::io::Write;
7635 let path = repo.root().join(".git").join("info").join("exclude");
7636 let mut file = std::fs::OpenOptions::new()
7637 .create(true)
7638 .append(true)
7639 .open(&path)
7640 .unwrap();
7641 for line in lines {
7642 writeln!(file, "{line}").unwrap();
7643 }
7644 }
7645
7646 #[test]
7647 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
7648 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
7649 exclude_paths(&repo, &["dist/"]);
7650 std::fs::create_dir_all(path.join("dist")).unwrap();
7651 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7652 let baseline = repo.worktree_baseline(&path).unwrap();
7653 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7654 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7655
7656 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
7657 repo.refuse_changed_existing_untracked(&path, &baseline)
7658 .unwrap();
7659 }
7660
7661 #[test]
7662 fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
7663 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
7664 exclude_paths(&repo, &["dist/", ".env.local"]);
7665 std::fs::create_dir_all(path.join("dist")).unwrap();
7666 let baseline = repo.worktree_baseline(&path).unwrap();
7667 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
7668 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
7669
7670 let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
7671
7672 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7673 assert!(error.to_string().contains(".env.local"), "{error}");
7674 }
7675
7676 #[test]
7677 fn a_read_only_inspection_may_rebuild_generated_output() {
7678 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
7679 exclude_paths(&repo, &["dist/"]);
7680 std::fs::create_dir_all(path.join("dist")).unwrap();
7681 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7682 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7683 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7684 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7685
7686 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7687 .unwrap();
7688 }
7689
7690 #[test]
7691 fn a_read_only_inspection_may_not_change_an_ignored_file_elsewhere() {
7692 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
7693 exclude_paths(&repo, &["dist/", ".env.local"]);
7694 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
7695 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7696 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
7697
7698 let error = repo
7699 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7700 .unwrap_err();
7701
7702 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7703 }
7704
7705 #[test]
7706 fn build_output_alone_does_not_keep_a_worktree() {
7707 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
7708 exclude_paths(&repo, &["target/", "dist/"]);
7709 std::fs::create_dir_all(path.join("target/debug")).unwrap();
7710 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7711 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
7712 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
7713
7714 assert!(!repository_has_recoverable_work(&path, true).unwrap());
7715 repo.release_review_worktree(933);
7716
7717 assert!(!path.exists());
7718 }
7719
7720 #[test]
7721 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
7722 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
7723 exclude_paths(&repo, &["target/", ".env.local"]);
7724 std::fs::create_dir_all(path.join("target/debug")).unwrap();
7725 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7726 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
7727
7728 assert!(repository_has_recoverable_work(&path, true).unwrap());
7729 repo.release_review_worktree(934);
7730
7731 assert_eq!(
7732 "TOKEN=keep me\n",
7733 std::fs::read_to_string(path.join(".env.local")).unwrap()
7734 );
7735 }
7736
7737 #[test]
7738 fn a_repository_nested_in_build_output_keeps_a_worktree() {
7739 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
7740 exclude_paths(&repo, &["node_modules/"]);
7741 let nested = path.join("node_modules/local-dep");
7742 std::fs::create_dir_all(&nested).unwrap();
7743 test_git(&nested, &["init"]);
7744 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
7745
7746 assert!(repository_has_recoverable_work(&path, true).unwrap());
7747 repo.release_review_worktree(935);
7748
7749 assert!(nested.join(".git").exists());
7750 }
7751
7752 #[test]
7753 fn an_ordinary_untracked_file_keeps_a_worktree() {
7754 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
7755 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
7756
7757 assert!(repository_has_recoverable_work(&path, true).unwrap());
7758 repo.release_review_worktree(936);
7759
7760 assert_eq!(
7761 "somebody's notes\n",
7762 std::fs::read_to_string(path.join("notes.md")).unwrap()
7763 );
7764 }
7765
7766 #[test]
7767 fn a_legacy_crlf_checkout_is_retained_conservatively() {
7768 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
7769 std::fs::write(
7770 path.join(".gitattributes"),
7771 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
7772 )
7773 .unwrap();
7774 test_git(&path, &["add", ".gitattributes"]);
7775 test_git(&path, &["commit", "-m", "select legacy line endings"]);
7776
7777 assert!(repository_has_recoverable_work(&path, true).unwrap());
7778 }
7779
7780 #[test]
7781 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
7782 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
7783 let nested = path.join("tracked");
7784 std::fs::create_dir_all(&nested).unwrap();
7785 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
7786 test_git(&path, &["add", "tracked/seed.txt"]);
7787 test_git(&path, &["commit", "-m", "add tracked directory"]);
7788 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7789 test_git(&nested, &["init"]);
7790
7791 let error = repo
7792 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7793 .unwrap_err();
7794
7795 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7796 assert!(error.to_string().contains("Git entry"), "{error}");
7797 assert!(nested.join(".git").exists());
7798 }
7799
7800 #[test]
7801 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
7802 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
7803
7804 let state = ignored_untracked_state(repo.root()).unwrap();
7805
7806 let relative = path.strip_prefix(repo.root()).unwrap();
7807 assert!(
7808 state.files.contains_key(relative),
7809 "{:?}",
7810 state.files.keys().collect::<Vec<_>>()
7811 );
7812 assert!(state.is_ignored(relative));
7813 }
7814
7815 #[test]
7816 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
7817 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
7818 let baseline = repo.worktree_baseline(repo.root()).unwrap();
7819 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
7820 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
7821
7822 repo.refuse_new_ignored_files(repo.root(), &baseline)
7823 .unwrap();
7824 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
7825 .unwrap();
7826 }
7827
7828 #[test]
7829 fn deleting_a_resident_worktree_during_a_call_is_refused() {
7830 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
7831 let baseline = repo.worktree_baseline(repo.root()).unwrap();
7832 std::fs::remove_dir_all(&path).unwrap();
7833
7834 let error = repo
7835 .refuse_new_ignored_files(repo.root(), &baseline)
7836 .unwrap_err();
7837
7838 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7839 assert!(error.to_string().contains("review-932"), "{error}");
7840 }
7841
7842 #[test]
7843 fn a_nested_repository_record_is_read_as_a_plain_path() {
7844 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
7845 assert_eq!(Path::new("vendor/checkout"), path);
7846 assert!(nested);
7847
7848 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
7849 assert_eq!(Path::new("vendor/notes.txt"), path);
7850 assert!(!nested);
7851
7852 assert!(untracked_record(b"/", "untracked").is_err());
7853 }
7854
7855 #[cfg(unix)]
7856 #[test]
7857 fn a_non_utf8_git_path_is_preserved_without_loss() {
7858 use std::os::unix::ffi::OsStrExt;
7859
7860 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
7861
7862 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
7863 }
7864
7865 #[test]
7866 fn guarded_merge_pins_the_reviewed_head() {
7867 let args = merge_pr_args("36", Some("abc123"), true);
7868 assert_eq!(
7869 vec![
7870 "pr",
7871 "merge",
7872 "36",
7873 "--squash",
7874 "--delete-branch",
7875 "--match-head-commit",
7876 "abc123"
7877 ],
7878 args
7879 );
7880 }
7881
7882 #[test]
7883 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
7884 let pr = PrRef {
7885 number: 7,
7886 url: "https://example.test/pull/7".into(),
7887 title: "part one".into(),
7888 };
7889 let result = reconcile_pr_creation(
7890 "split-34-1",
7891 Err(crate::error::SparError::new("connection lost")),
7892 Ok(Some(pr)),
7893 )
7894 .unwrap();
7895 assert_eq!(7, result.number);
7896 }
7897
7898 #[test]
7899 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
7900 let error = reconcile_pr_creation(
7901 "split-34-1",
7902 Err(crate::error::SparError::new("permission denied")),
7903 Ok(None),
7904 )
7905 .unwrap_err();
7906 assert!(error.to_string().contains("permission denied"), "{error}");
7907 }
7908
7909 #[test]
7910 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
7911 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
7912 assert!(pr_for_base(text, "split-34-2", "split-34-1")
7913 .unwrap()
7914 .is_none());
7915 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
7916 assert_eq!(7, found.number);
7917 }
7918
7919 #[test]
7920 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
7921 let result = reconcile_comment_post(
7922 34,
7923 "the summary",
7924 crate::error::SparError::new("connection lost"),
7925 Ok(vec![serde_json::json!({"body": "the summary"})]),
7926 );
7927 assert!(result.is_ok(), "{result:?}");
7928 }
7929
7930 #[test]
7931 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
7932 let error = reconcile_comment_post(
7933 34,
7934 "the summary",
7935 crate::error::SparError::new("connection lost"),
7936 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
7937 )
7938 .unwrap_err();
7939 assert_eq!("connection lost", error.to_string());
7940 }
7941
7942 #[test]
7943 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
7944 let error = reconcile_comment_post(
7945 34,
7946 "the summary",
7947 crate::error::SparError::new("connection lost"),
7948 Err(crate::error::SparError::new("comments unavailable")),
7949 )
7950 .unwrap_err();
7951 assert!(
7952 error.to_string().contains("could not be verified"),
7953 "{error}"
7954 );
7955 assert!(
7956 error.to_string().contains("comments unavailable"),
7957 "{error}"
7958 );
7959 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7960 assert!(!error.worth_retrying());
7961 }
7962
7963 #[test]
7964 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
7965 let result = reconcile_issue_edit(
7966 34,
7967 "wanted body",
7968 crate::error::SparError::new("connection lost"),
7969 Ok("wanted body".to_string()),
7970 );
7971 assert!(result.is_ok(), "{result:?}");
7972 }
7973
7974 #[test]
7975 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
7976 let error = reconcile_issue_edit(
7977 34,
7978 "wanted body",
7979 crate::error::SparError::new("connection lost"),
7980 Err(crate::error::SparError::new("issue unavailable")),
7981 )
7982 .unwrap_err();
7983 assert!(
7984 error.to_string().contains("could not be verified"),
7985 "{error}"
7986 );
7987 assert!(error.to_string().contains("issue unavailable"), "{error}");
7988 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7989 assert!(!error.worth_retrying());
7990 }
7991
7992 #[test]
7993 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
7994 let found = ExistingIssue {
7995 number: 101,
7996 url: "https://example.test/issues/101".into(),
7997 title: "child".into(),
7998 body: "body".into(),
7999 open: true,
8000 };
8001 let url = reconcile_issue_creation(
8002 "child",
8003 Err(crate::error::SparError::new("connection lost")),
8004 Ok(Some(found)),
8005 )
8006 .unwrap();
8007 assert_eq!("https://example.test/issues/101", url);
8008 }
8009
8010 #[test]
8011 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8012 let error = reconcile_issue_creation(
8013 "child",
8014 Err(crate::error::SparError::new("permission denied")),
8015 Ok(None),
8016 )
8017 .unwrap_err();
8018 assert!(error.to_string().contains("permission denied"), "{error}");
8019 }
8020
8021 #[test]
8022 fn an_unverifiable_issue_creation_is_marked_uncertain() {
8023 let error = reconcile_issue_creation(
8024 "child",
8025 Err(crate::error::SparError::new("connection lost")),
8026 Err(crate::error::SparError::new("issues unavailable")),
8027 )
8028 .unwrap_err();
8029 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8030 assert!(!error.worth_retrying());
8031 }
8032
8033 #[test]
8034 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8035 let result = reconcile_failed_split_push(
8036 "split-34-1",
8037 crate::error::SparError::new("connection lost"),
8038 Ok("abc123\n".into()),
8039 Ok("abc123\trefs/heads/split-34-1\n".into()),
8040 );
8041 assert!(result.is_ok(), "{result:?}");
8042 }
8043
8044 #[test]
8045 fn a_split_push_collision_is_definite_and_never_overwrites() {
8046 let error = reconcile_failed_split_push(
8047 "split-34-1",
8048 crate::error::SparError::new("lease rejected"),
8049 Ok("abc123\n".into()),
8050 Ok("def456\trefs/heads/split-34-1\n".into()),
8051 )
8052 .unwrap_err();
8053 assert!(!error.retain_worktree());
8054 assert!(
8055 error.to_string().contains("Nothing was overwritten"),
8056 "{error}"
8057 );
8058 }
8059
8060 #[test]
8061 fn an_unreadable_split_push_result_keeps_the_worktree() {
8062 let error = reconcile_failed_split_push(
8063 "split-34-1",
8064 crate::error::SparError::new("connection lost"),
8065 Ok("abc123\n".into()),
8066 Err(crate::error::SparError::new("origin unavailable")),
8067 )
8068 .unwrap_err();
8069 assert!(error.retain_worktree());
8070 assert!(error.to_string().contains("could not confirm"), "{error}");
8071 }
8072
8073 #[test]
8077 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8078 let repo = repo_for_titles();
8079 for raw in [
8080 "Retry loop spins \u{2014} Retry-After parses to zero",
8081 "plain title",
8082 " spread over\nlines ",
8083 "\u{1F916} Generated with something",
8084 &format!("a \u{2014} {}", "very long title ".repeat(20)),
8085 &"x".repeat(300),
8086 &format!("{} \u{2014} end", "y".repeat(88)),
8087 &{
8092 let tail = "a\u{2014}b c\u{2014}d";
8093 let pad = Style::default().max_title_chars - tail.chars().count();
8094 format!("{}{tail}", "w".repeat(pad))
8095 },
8096 ] {
8097 let once = repo.clean_title(raw).unwrap();
8098 let twice = repo.clean_title(&once).unwrap();
8099 assert_eq!(once, twice, "not idempotent for {raw:?}");
8100 assert!(
8101 once.chars().count() <= repo.style.max_title_chars,
8102 "over budget: {once:?}"
8103 );
8104 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8105 }
8106 }
8107
8108 #[test]
8109 fn a_title_with_an_em_dash_survives_as_readable_text() {
8110 let repo = repo_for_titles();
8111 assert_eq!(
8112 "Retry loop spins, Retry-After parses to zero",
8113 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8114 .unwrap()
8115 );
8116 }
8117
8118 #[test]
8119 fn sh_quote_survives_a_quote() {
8120 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
8121 }
8122
8123 #[test]
8124 fn sh_quote_wraps_a_space() {
8125 assert_eq!(
8126 "'/Applications/My App/spar'",
8127 sh_quote("/Applications/My App/spar")
8128 );
8129 }
8130
8131 #[test]
8132 fn finished_states_are_recognised_case_insensitively() {
8133 assert!(is_finished("MERGED"));
8134 assert!(is_finished("closed"));
8135 assert!(!is_finished("OPEN"));
8136 assert!(!is_finished(""));
8137 }
8138
8139 fn state() -> PersistedState {
8140 PersistedState {
8141 version: 1,
8142 checkpoint: 0,
8143 round: 4,
8144 next_actor: "codex".into(),
8145 status: Status::Pending,
8146 pr_head: "abc123".into(),
8147 ledger: Ledger::new(),
8148 filed: vec![],
8149 open_findings: vec![Finding {
8150 severity: Severity::Blocking,
8151 title: "Unchecked error".into(),
8152 detail: "the failure is discarded".into(),
8153 file: "src/a.rs:12".into(),
8154 ..Finding::default()
8155 }],
8156 disputes: vec![Dispute {
8157 title: "Retry limit".into(),
8158 file: "src/net.rs".into(),
8159 reasoning: "the caller already bounds it".into(),
8160 }],
8161 noted: vec![Finding {
8162 severity: Severity::NonBlocking,
8163 title: "Timeout is fixed".into(),
8164 file: "src/config.rs".into(),
8165 ..Finding::default()
8166 }],
8167 }
8168 }
8169
8170 #[test]
8171 fn a_state_comment_round_trips() {
8172 let body = format!(
8173 "{STATE_MARKER}\n{}\n-->",
8174 serde_json::to_string(&state()).unwrap()
8175 );
8176 let back = parse_state_comment(&body).unwrap();
8177 assert_eq!(4, back.round);
8178 assert_eq!("codex", back.next_actor);
8179 assert_eq!("abc123", back.pr_head);
8180 assert_eq!("Unchecked error", back.open_findings[0].title);
8181 assert_eq!("src/net.rs", back.disputes[0].file);
8182 assert_eq!("Timeout is fixed", back.noted[0].title);
8183 }
8184
8185 #[test]
8186 fn old_state_without_new_lists_still_parses() {
8187 let body = format!(
8188 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
8189 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8190 );
8191 let back = parse_state_comment(&body).expect("old state");
8192 assert!(back.open_findings.is_empty());
8193 assert!(back.disputes.is_empty());
8194 assert!(back.noted.is_empty());
8195 assert!(back.pr_head.is_empty());
8196 assert_eq!(0, back.checkpoint);
8197 }
8198
8199 #[test]
8200 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
8201 let mut local = state();
8202 local.pr_head = "old".into();
8203 local.round = 9;
8204 let mut remote = state();
8205 remote.pr_head = "current".into();
8206 remote.round = 4;
8207
8208 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8209 assert_eq!("current", chosen.pr_head);
8210 assert_eq!(4, chosen.round);
8211 }
8212
8213 #[test]
8214 fn checkpoint_order_breaks_same_round_ties() {
8215 let mut local = state();
8216 local.pr_head = "current".into();
8217 local.round = 4;
8218 local.checkpoint = 8;
8219 let mut remote = local.clone();
8220 remote.checkpoint = 7;
8221 remote.open_findings.clear();
8222
8223 let chosen = choose_state_for_head(vec![local], "current").unwrap();
8224 assert_eq!(8, chosen.checkpoint);
8225
8226 let mut local = state();
8227 local.pr_head = "current".into();
8228 local.round = 4;
8229 local.checkpoint = 8;
8230 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
8231 assert_eq!(8, chosen.checkpoint);
8232 }
8233
8234 #[test]
8235 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
8236 let mut local = state();
8237 local.pr_head = "current".into();
8238 local.round = 4;
8239 local.open_findings.push(Finding {
8240 title: "local checkpoint".into(),
8241 ..Finding::default()
8242 });
8243 let mut remote = state();
8244 remote.pr_head = "current".into();
8245 remote.round = 4;
8246
8247 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8248 assert_eq!(
8249 "local checkpoint",
8250 chosen.open_findings.last().unwrap().title
8251 );
8252 }
8253
8254 #[test]
8256 fn the_state_block_is_an_html_comment() {
8257 let body = format!(
8258 "{STATE_MARKER}\n{}\n-->",
8259 serde_json::to_string(&state()).unwrap()
8260 );
8261 assert!(body.starts_with("<!--"));
8262 assert!(body.trim_end().ends_with("-->"));
8263 assert!(!body[..body.find('{').unwrap()].contains("-->"));
8264 }
8265
8266 #[test]
8267 fn an_unrelated_json_block_is_not_state() {
8268 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
8269 }
8270
8271 #[test]
8278 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
8279 let block = format!(
8280 "{STATE_MARKER}\n{}\n-->",
8281 serde_json::to_string(&state()).unwrap()
8282 );
8283 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
8284 assert!(
8285 is_state_comment(&block, "Spar-Bot", "spar-bot"),
8286 "a login differs only in case"
8287 );
8288
8289 assert!(
8290 !is_state_comment(
8291 "why is there a `<!-- spar:state` block on this PR?",
8292 "maintainer",
8293 "spar-bot"
8294 ),
8295 "a question about the block was treated as the block"
8296 );
8297 assert!(
8298 !is_state_comment(&block, "maintainer", "spar-bot"),
8299 "somebody else's comment is not spar's to rewrite"
8300 );
8301 assert!(
8302 !is_state_comment(
8303 &format!("here is what spar writes:\n\n{block}"),
8304 "spar-bot",
8305 "spar-bot"
8306 ),
8307 "the marker has to open the body, not appear in it"
8308 );
8309 assert!(
8310 !is_state_comment(
8311 &format!("{STATE_MARKER}\n{{not json\n-->"),
8312 "spar-bot",
8313 "spar-bot"
8314 ),
8315 "a block that does not parse is not state"
8316 );
8317 assert!(
8318 !is_state_comment(&block, "spar-bot", ""),
8319 "an unknown viewer proves nothing about who wrote it"
8320 );
8321 }
8322
8323 #[test]
8324 fn a_malformed_state_comment_is_none_not_a_panic() {
8325 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
8326 }
8327
8328 #[test]
8329 fn atomic_write_leaves_no_temp_file() {
8330 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
8331 let _ = std::fs::remove_dir_all(&dir);
8332 let path = dir.join("state").join("pr-7.json");
8333 write_json_atomic(&path, &state()).unwrap();
8334 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
8335 .unwrap()
8336 .flatten()
8337 .filter_map(|e| e.file_name().to_str().map(str::to_string))
8338 .collect();
8339 assert_eq!(vec!["pr-7.json".to_string()], files);
8340 let _ = std::fs::remove_dir_all(&dir);
8341 }
8342
8343 #[test]
8344 fn atomic_write_overwrites_rather_than_accumulating() {
8345 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
8346 let _ = std::fs::remove_dir_all(&dir);
8347 let path = dir.join("pr-7.json");
8348 for round in 1..4 {
8349 let mut s = state();
8350 s.round = round;
8351 write_json_atomic(&path, &s).unwrap();
8352 }
8353 let back: PersistedState =
8354 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
8355 assert_eq!(3, back.round);
8356 let _ = std::fs::remove_dir_all(&dir);
8357 }
8358
8359 #[test]
8360 fn style_from_env_defaults_to_enforcing() {
8361 std::env::remove_var("SPAR_BAN_EM_DASH");
8362 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
8363 let style = style_from_env();
8364 assert!(style.ban_em_dash && style.ban_ai_attribution);
8365 assert!(
8366 !style.terse,
8367 "the commit filter must not truncate a commit message"
8368 );
8369 }
8370}
8371
8372#[cfg(test)]
8373mod comment_page_tests {
8374 use super::*;
8375
8376 #[test]
8377 fn a_single_merged_array_is_read() {
8378 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
8379 assert_eq!(2, pages.len());
8380 assert_eq!(Some(2), pages[1]["id"].as_i64());
8381 }
8382
8383 #[test]
8384 fn concatenated_pages_from_an_older_gh_are_read_too() {
8385 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
8386 assert_eq!(2, pages.len());
8387 }
8388
8389 #[test]
8393 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
8394 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
8395 let pages = parse_comment_pages(text);
8396 assert_eq!(2, pages.len(), "{pages:?}");
8397 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
8398 }
8399
8400 #[test]
8401 fn empty_output_is_no_comments_not_a_panic() {
8402 assert!(parse_comment_pages("").is_empty());
8403 assert!(parse_comment_pages(" ").is_empty());
8404 assert!(parse_comment_pages("[]").is_empty());
8405 }
8406
8407 #[test]
8408 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
8409 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
8410 }
8411
8412 #[test]
8413 fn a_write_postcheck_rejects_truncated_comment_pages() {
8414 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
8415 assert!(
8416 error.to_string().contains("unexpected comment pages"),
8417 "{error}"
8418 );
8419 }
8420
8421 #[test]
8422 fn a_write_postcheck_rejects_empty_or_non_array_output() {
8423 assert!(try_parse_comment_pages("").is_err());
8424 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
8425 assert!(try_parse_comment_pages("[]").is_ok());
8426 }
8427
8428 #[test]
8429 fn state_is_found_in_the_last_matching_comment() {
8430 let payload = |round: u32| {
8431 format!(
8432 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8433 )
8434 };
8435 let text = serde_json::to_string(&serde_json::json!([
8436 {"id": 1, "body": payload(1)},
8437 {"id": 2, "body": "looks good to me"},
8438 {"id": 3, "body": payload(5)},
8439 ]))
8440 .unwrap();
8441 let pages = parse_comment_pages(&text);
8442 let last = pages
8443 .iter()
8444 .rev()
8445 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
8446 .unwrap();
8447 assert_eq!(5, last.round);
8448 }
8449}
8450
8451#[cfg(test)]
8452mod linked_pr_tests {
8453 use super::*;
8454
8455 const REAL_PAYLOAD: &str = r#"[
8460 {"number":14252,"title":"fix: reject leading-dash branch names",
8461 "url":"https://github.com/cli/cli/pull/14252",
8462 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
8463 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8464 "url":"https://github.com/cli/cli/issues/14238"}]},
8465 {"number":14217,"title":"another change",
8466 "url":"https://github.com/cli/cli/pull/14217",
8467 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
8468 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8469 "url":"https://github.com/cli/cli/issues/9761"}]},
8470 {"number":14200,"title":"unlinked work",
8471 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
8472 ]"#;
8473
8474 #[test]
8475 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
8476 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
8477 assert_eq!(14252, pr.number);
8478 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
8479 }
8480
8481 #[test]
8482 fn the_right_pr_is_picked_out_of_several() {
8483 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
8484 }
8485
8486 #[test]
8487 fn an_issue_nobody_is_working_on_finds_nothing() {
8488 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
8489 }
8490
8491 #[test]
8492 fn an_unlinked_pr_is_never_matched() {
8493 for issue in [14200, 0, 1] {
8495 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
8496 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
8497 }
8498 }
8499 }
8500
8501 #[test]
8502 fn empty_or_broken_output_is_none_rather_than_a_panic() {
8503 assert!(find_linked_pr("", 1).is_none());
8504 assert!(find_linked_pr("[]", 1).is_none());
8505 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
8506 assert!(find_linked_pr("[{\"number\":", 1).is_none());
8507 }
8508
8509 #[test]
8516 fn an_unreadable_list_is_not_an_empty_list() {
8517 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
8518 assert!(linked_pr_rows("[{\"number\":").is_none());
8519 assert!(linked_pr_rows("").is_none());
8520
8521 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
8522 assert!(empty.is_empty());
8523 assert!(pick_linked_pr(&empty, 14238).is_none());
8524
8525 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
8526 assert_eq!(3, listed.len());
8527 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
8528 }
8529
8530 #[test]
8532 fn pr_view_reads_the_cross_repository_flag() {
8533 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
8534 "baseRefName":"main","state":"OPEN",
8535 "closingIssuesReferences":[],"isCrossRepository":true}"#;
8536 let pr: PrView = serde_json::from_str(json).unwrap();
8537 assert!(pr.is_cross_repository);
8538 assert!(pr.is_open());
8539
8540 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
8541 assert!(
8542 !serde_json::from_str::<PrView>(&same_repo)
8543 .unwrap()
8544 .is_cross_repository
8545 );
8546 }
8547}
8548
8549#[cfg(test)]
8550mod min_number_tests {
8551 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
8557 let mut numbers: Vec<i64> = open.to_vec();
8558 numbers.sort_unstable();
8559 if min_number > 0 {
8560 numbers.retain(|n| *n >= min_number);
8561 }
8562 numbers.truncate(limit);
8563 numbers
8564 }
8565
8566 #[test]
8567 fn the_floor_is_applied_before_the_cap_not_after() {
8568 let open = [12, 13, 14, 480, 481, 482];
8569 assert_eq!(vec![480, 481], pick(&open, 2, 480));
8570 assert!(!pick(&open, 2, 480).is_empty());
8573 }
8574
8575 #[test]
8576 fn no_floor_keeps_the_old_behaviour() {
8577 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
8578 }
8579
8580 #[test]
8581 fn the_floor_is_inclusive() {
8582 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
8583 }
8584
8585 #[test]
8586 fn a_floor_above_everything_open_yields_nothing() {
8587 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
8588 }
8589}