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>> {
5908 let root = std::fs::canonicalize(cwd)
5909 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5910 let mut allowed = BTreeSet::from([root.join(".git")]);
5911 let mut checkouts = BTreeSet::new();
5912 let mut repositories = vec![root.clone()];
5913 let mut visited = BTreeSet::new();
5914 while let Some(repository) = repositories.pop() {
5915 let canonical = std::fs::canonicalize(&repository)
5916 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5917 if !visited.insert(canonical.clone()) {
5918 bail!("submodule recursion revisited {}", canonical.display());
5919 }
5920 for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
5921 let (path, nested) = untracked_record(&raw, "untracked")?;
5922 if nested {
5923 checkouts.insert(canonical.join(path));
5924 }
5925 }
5926 for link in gitlinks(&canonical)? {
5927 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
5928 continue;
5929 };
5930 allowed.insert(submodule.join(".git"));
5931 repositories.push(submodule);
5932 }
5933 }
5934
5935 let scan_root = root.clone();
5936 let mut directories = vec![root];
5937 while let Some(directory) = directories.pop() {
5938 let entries = std::fs::read_dir(&directory)
5939 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5940 for entry in entries {
5941 let entry =
5942 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5943 let path = entry.path();
5944 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
5945 continue;
5946 }
5947 if entry.file_name() == OsStr::new(".git") {
5948 if !allowed.contains(&path) {
5949 return Ok(Some(path));
5950 }
5951 continue;
5952 }
5953 let kind = entry
5954 .file_type()
5955 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5956 if kind.is_dir() && !checkouts.contains(&path) {
5957 directories.push(path);
5958 }
5959 }
5960 }
5961 Ok(None)
5962}
5963
5964pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
5965 if let Some(path) = unexpected_nested_git_entry(cwd)? {
5966 bail!(
5967 "the worktree contains a Git entry at {} that Git does not report as a nested \
5968 checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
5969 prove they are unchanged.",
5970 path.display()
5971 );
5972 }
5973 let root = std::fs::canonicalize(cwd)
5974 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5975 let mut repositories = BTreeMap::new();
5976 let mut visited = BTreeSet::new();
5977 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
5978 Ok(GitState { repositories })
5979}
5980
5981fn collect_git_state(
5982 repository: &Path,
5983 prefix: &Path,
5984 visited: &mut BTreeSet<PathBuf>,
5985 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
5986) -> Result<()> {
5987 let canonical = std::fs::canonicalize(repository)
5988 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5989 if !visited.insert(canonical.clone()) {
5990 bail!("submodule recursion revisited {}", canonical.display());
5991 }
5992 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5993 let head = head.trim().to_string();
5994 if head.is_empty() {
5995 bail!("git returned an empty head for {}", repository.display());
5996 }
5997 let unsafe_index_flags = unsafe_index_flags(repository)?;
5998 let tracked = tracked_entries(repository)?;
5999 let gitlinks = gitlinks(repository)?;
6000 if repositories
6001 .insert(
6002 prefix.to_path_buf(),
6003 RepositoryState {
6004 head,
6005 unsafe_index_flags,
6006 tracked,
6007 gitlinks: gitlinks
6008 .iter()
6009 .map(|link| (link.path.clone(), link.oid.clone()))
6010 .collect(),
6011 },
6012 )
6013 .is_some()
6014 {
6015 bail!("Git state contains duplicate repository path {:?}", prefix);
6016 }
6017
6018 for link in gitlinks {
6019 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6020 continue;
6021 };
6022 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6023 }
6024 Ok(())
6025}
6026
6027fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6028 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6029 if !listed.is_empty() && !listed.ends_with(&[0]) {
6030 bail!(
6031 "git returned an unterminated index-flag listing for {}",
6032 cwd.display()
6033 );
6034 }
6035 let mut unsafe_records = Vec::new();
6036 for record in listed
6037 .split(|byte| *byte == 0)
6038 .filter(|record| !record.is_empty())
6039 {
6040 if record.len() < 3 || record[1] != b' ' {
6041 bail!(
6042 "git returned a malformed index-flag record for {}",
6043 cwd.display()
6044 );
6045 }
6046 if record[0] != b'H' {
6047 unsafe_records.extend_from_slice(record);
6048 unsafe_records.push(0);
6049 }
6050 }
6051 Ok(unsafe_records)
6052}
6053
6054pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6055 safe_git_state(cwd).map(|_| ())
6056}
6057
6058pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6059 let state = git_state(cwd)?;
6060 if let Some((path, _repository)) = state
6061 .repositories
6062 .iter()
6063 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6064 {
6065 let label = if path.as_os_str().is_empty() {
6066 cwd.to_path_buf()
6067 } else {
6068 cwd.join(path)
6069 };
6070 bail!(
6071 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6072 SPAR cannot prove the working files are unchanged, so it was kept.",
6073 label.display()
6074 );
6075 }
6076 Ok(state)
6077}
6078
6079fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6080 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6081 return Ok(true);
6082 }
6083 let mut visited = BTreeSet::new();
6084 repository_has_recoverable_work_inner(cwd, include_ignored, &mut visited)
6085}
6086
6087fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6088 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6089 let git_dir = PathBuf::from(git_dir.trim());
6090 let git_dir = if git_dir.is_absolute() {
6091 git_dir
6092 } else {
6093 cwd.join(git_dir)
6094 };
6095 let git_dir = std::fs::canonicalize(&git_dir)
6096 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6097 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6098 Ok(_) => return Ok(true),
6099 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6100 Err(error) => {
6101 return Err(spar_err!(
6102 "could not inspect per-worktree configuration in {}: {error}",
6103 git_dir.display()
6104 ))
6105 }
6106 }
6107
6108 let orig_head = git_dir.join("ORIG_HEAD");
6109 match std::fs::symlink_metadata(&orig_head) {
6110 Ok(metadata) if metadata.is_file() => {
6111 let oid = std::fs::read_to_string(&orig_head)
6112 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6113 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6114 return Ok(true);
6115 };
6116 if !commit_has_shared_ref(cwd, &commit)? {
6117 return Ok(true);
6118 }
6119 }
6120 Ok(_) => return Ok(true),
6121 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6122 Err(error) => {
6123 return Err(spar_err!(
6124 "could not inspect {}: {error}",
6125 orig_head.display()
6126 ))
6127 }
6128 }
6129
6130 let edit_message = git_dir.join("COMMIT_EDITMSG");
6131 match std::fs::symlink_metadata(&edit_message) {
6132 Ok(metadata) if metadata.is_file() => {
6133 let draft = std::fs::read(&edit_message)
6134 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6135 if draft != head_commit_message(cwd)? {
6136 return Ok(true);
6137 }
6138 }
6139 Ok(_) => return Ok(true),
6140 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6141 Err(error) => {
6142 return Err(spar_err!(
6143 "could not inspect {}: {error}",
6144 edit_message.display()
6145 ))
6146 }
6147 }
6148
6149 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6150 return Ok(true);
6151 }
6152
6153 let local_refs = run_git_bytes(
6154 cwd,
6155 &[
6156 "for-each-ref",
6157 "--format=%(refname)",
6158 "refs/worktree",
6159 "refs/bisect",
6160 "refs/rewritten",
6161 ],
6162 )?;
6163 if !local_refs.is_empty() {
6164 return Ok(true);
6165 }
6166
6167 for entry in std::fs::read_dir(&git_dir)
6168 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6169 {
6170 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6171 let known = matches!(
6172 entry.file_name().to_str(),
6173 Some(
6174 "HEAD"
6175 | "ORIG_HEAD"
6176 | "COMMIT_EDITMSG"
6177 | "commondir"
6178 | "gitdir"
6179 | "index"
6180 | "logs"
6181 | "refs"
6182 )
6183 );
6184 if !known {
6185 return Ok(true);
6186 }
6187 }
6188
6189 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6190 if !commit_has_shared_ref(cwd, head.trim())? {
6191 return Ok(true);
6192 }
6193 Ok(false)
6194}
6195
6196fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6197 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6198 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6199 bail!(
6200 "git returned a commit without a message separator in {}",
6201 cwd.display()
6202 );
6203 };
6204 Ok(commit[split + 2..].to_vec())
6205}
6206
6207fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6208 let metadata = match std::fs::symlink_metadata(logs) {
6209 Ok(metadata) => metadata,
6210 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6211 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6212 };
6213 if !metadata.is_dir() {
6214 return Ok(true);
6215 }
6216 let mut files = Vec::new();
6217 let mut directories = vec![logs.to_path_buf()];
6218 while let Some(directory) = directories.pop() {
6219 for entry in std::fs::read_dir(&directory)
6220 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6221 {
6222 let entry =
6223 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6224 let path = entry.path();
6225 let kind = entry
6226 .file_type()
6227 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6228 if kind.is_dir() {
6229 directories.push(path);
6230 } else if kind.is_file() {
6231 files.push(path);
6232 } else {
6233 return Ok(true);
6234 }
6235 }
6236 }
6237
6238 let mut commits = BTreeSet::new();
6239 for path in files {
6240 if !collect_reflog_commits(cwd, &path, &mut commits)? {
6241 return Ok(true);
6242 }
6243 }
6244 for commit in commits {
6245 if !commit_has_shared_ref(cwd, &commit)? {
6246 return Ok(true);
6247 }
6248 }
6249 Ok(false)
6250}
6251
6252fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6256 let common = common_git_dir(cwd)?;
6257 let reflog = common.join("logs").join(refname);
6258 let metadata = match std::fs::symlink_metadata(&reflog) {
6259 Ok(metadata) => metadata,
6260 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6261 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6262 };
6263 if !metadata.is_file() {
6264 return Ok(false);
6265 }
6266 let mut commits = BTreeSet::new();
6267 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6268 return Ok(false);
6269 }
6270 for commit in commits {
6271 if is_ancestor(cwd, &commit, durable_tip)?
6272 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6273 {
6274 continue;
6275 }
6276 return Ok(false);
6277 }
6278 Ok(true)
6279}
6280
6281fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6282 let file = std::fs::File::open(path)
6283 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6284 for line in std::io::BufReader::new(file).lines() {
6285 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6286 let mut fields = line.splitn(3, ' ');
6287 let Some(old) = fields.next() else {
6288 return Ok(false);
6289 };
6290 let Some(new) = fields.next() else {
6291 return Ok(false);
6292 };
6293 if fields.next().is_none() {
6294 return Ok(false);
6295 }
6296 for oid in [old, new] {
6297 if oid.bytes().all(|byte| byte == b'0') {
6298 continue;
6299 }
6300 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6301 return Ok(false);
6302 };
6303 commits.insert(commit);
6304 }
6305 }
6306 Ok(true)
6307}
6308
6309fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6310 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6311 let path = PathBuf::from(raw.trim());
6312 let path = if path.is_absolute() {
6313 path
6314 } else {
6315 cwd.join(path)
6316 };
6317 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6318}
6319
6320fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6321 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6322 let output = proc::exec(
6323 &argv,
6324 &ExecOpts::new()
6325 .cwd(cwd)
6326 .timeout_secs(30)
6327 .check(false)
6328 .stop_descendants(true),
6329 )?;
6330 match output.code {
6331 0 => Ok(true),
6332 1 => Ok(false),
6333 _ => bail!("{}", proc::failure_message(&argv, &output)),
6334 }
6335}
6336
6337fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6338 let commit = format!("{oid}^{{commit}}");
6339 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6340 let output = proc::exec(
6341 &argv,
6342 &ExecOpts::new()
6343 .cwd(cwd)
6344 .timeout_secs(30)
6345 .check(false)
6346 .stop_descendants(true),
6347 )?;
6348 if output.code != 0 {
6349 return Ok(None);
6350 }
6351 let oid = output.stdout.trim();
6352 if oid.is_empty() {
6353 return Ok(None);
6354 }
6355 Ok(Some(oid.to_string()))
6356}
6357
6358fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6359 commit_has_shared_ref_except(cwd, oid, None)
6360}
6361
6362fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6363 let contains = format!("--contains={oid}");
6364 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6365 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6366 !record.is_empty()
6367 && !record.starts_with(b"refs/worktree/")
6368 && !record.starts_with(b"refs/bisect/")
6369 && !record.starts_with(b"refs/rewritten/")
6370 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6371 }))
6372}
6373
6374fn has_untracked_work_worth_keeping(cwd: &Path) -> Result<bool> {
6389 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6390 if !ordinary.is_empty() {
6391 return Ok(true);
6392 }
6393 let listed = untracked_listing(
6394 cwd,
6395 &[
6396 "ls-files",
6397 "--others",
6398 "--ignored",
6399 "--exclude-standard",
6400 "-z",
6401 ],
6402 )?;
6403 for raw in listed {
6404 let (path, nested) = untracked_record(&raw, "ignored")?;
6405 if nested || !is_generated_artifact(&path) {
6406 return Ok(true);
6407 }
6408 }
6409 Ok(false)
6410}
6411
6412fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6413 let listed = run_git_bytes(cwd, args)?;
6414 if !listed.is_empty() && !listed.ends_with(&[0]) {
6415 bail!(
6416 "git returned an unterminated untracked-file list for {}",
6417 cwd.display()
6418 );
6419 }
6420 Ok(listed
6421 .split(|byte| *byte == 0)
6422 .filter(|raw| !raw.is_empty())
6423 .map(|raw| raw.to_vec())
6424 .collect())
6425}
6426
6427fn repository_has_recoverable_work_inner(
6428 cwd: &Path,
6429 include_ignored: bool,
6430 visited: &mut BTreeSet<PathBuf>,
6431) -> Result<bool> {
6432 let canonical = std::fs::canonicalize(cwd)
6433 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6434 if !visited.insert(canonical.clone()) {
6435 bail!("submodule recursion revisited {}", canonical.display());
6436 }
6437 if include_ignored && has_untracked_work_worth_keeping(cwd)? {
6438 return Ok(true);
6439 }
6440 if !unsafe_index_flags(cwd)?.is_empty() {
6441 return Ok(true);
6442 }
6443 if attributes_may_be_modified(cwd)? {
6444 return Ok(true);
6445 }
6446 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6447 return Ok(true);
6448 }
6449 if include_ignored {
6450 let index = index_entries(cwd)?
6451 .into_iter()
6452 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6453 .collect::<BTreeMap<_, _>>();
6454 let head = tree_entries(cwd, "HEAD")?
6455 .into_iter()
6456 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6457 .collect::<BTreeMap<_, _>>();
6458 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6459 return Ok(true);
6460 }
6461 let tracked = tracked_entries(cwd)?;
6462 let effective = check_attributes(cwd, tracked.keys().cloned())?;
6463 let config = CheckoutConfig::read(cwd)?;
6464 for (path, entry) in tracked {
6465 let Some(worktree) = entry.worktree else {
6466 return Ok(true);
6467 };
6468 let attributes = effective.get(&path).ok_or_else(|| {
6469 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6470 })?;
6471 if path_has_ambiguous_transform(&config, attributes)? {
6472 return Ok(true);
6473 }
6474 let symlink_file = entry.index_mode == "120000"
6475 && worktree.mode == "100644"
6476 && worktree.raw_oid == entry.index_oid
6477 && config.symlinks == Some(false);
6478 if worktree.mode != entry.index_mode && !symlink_file {
6479 return Ok(true);
6480 }
6481 if entry.index_mode == "120000" {
6482 if worktree.raw_oid != entry.index_oid {
6483 return Ok(true);
6484 }
6485 continue;
6486 }
6487 #[cfg(unix)]
6488 {
6489 let expected = if entry.index_mode == "100755" {
6490 0o755
6491 } else {
6492 0o644
6493 };
6494 if worktree.permissions != expected {
6495 return Ok(true);
6496 }
6497 }
6498 if allows_expected_crlf(&config, attributes)? {
6499 let (normalized, every_lf_was_crlf) =
6500 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6501 if !every_lf_was_crlf || normalized != entry.index_oid {
6502 return Ok(true);
6503 }
6504 } else if worktree.raw_oid != entry.index_oid {
6505 return Ok(true);
6506 }
6507 }
6508 } else {
6509 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6510 if !run_git_bytes(cwd, &args)?.is_empty() {
6511 return Ok(true);
6512 }
6513 }
6514 for link in gitlinks(cwd)? {
6515 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6516 continue;
6517 };
6518 if include_ignored {
6523 return Ok(true);
6524 }
6525 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6526 if head.trim() != link.oid {
6527 return Ok(true);
6528 }
6529 if repository_has_recoverable_work_inner(&submodule, include_ignored, visited)? {
6530 return Ok(true);
6531 }
6532 }
6533 Ok(false)
6534}
6535
6536pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6537 repository_has_recoverable_work(cwd, false)
6538}
6539
6540fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6541 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6542 Ok(!run_git_bytes(cwd, &args)?.is_empty())
6543}
6544
6545#[cfg(unix)]
6546fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6547 use std::os::unix::ffi::OsStringExt;
6548 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6549}
6550
6551#[cfg(not(unix))]
6552fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6553 String::from_utf8(raw.to_vec())
6554 .map(PathBuf::from)
6555 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6556}
6557
6558fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
6567 let metadata = std::fs::symlink_metadata(path).map_err(|e| {
6568 spar_err!(
6569 "could not inspect the nested repository at {}: {e}",
6570 path.display()
6571 )
6572 })?;
6573 if !metadata.is_dir() {
6574 bail!(
6575 "git reported {} as a nested repository, but it is not a directory",
6576 path.display()
6577 );
6578 }
6579 if std::fs::symlink_metadata(path.join(".git")).is_err() {
6580 bail!(
6581 "git reported {} as a nested repository, but it has no Git entry",
6582 path.display()
6583 );
6584 }
6585 #[cfg(unix)]
6586 {
6587 use std::os::unix::fs::MetadataExt;
6588 Ok(UntrackedFile {
6589 kind: 3,
6590 len: 0,
6591 modified: None,
6592 created: metadata.created().ok(),
6593 readonly: metadata.permissions().readonly(),
6594 symlink_target: None,
6595 device: metadata.dev(),
6596 inode: metadata.ino(),
6597 mode: metadata.mode(),
6598 change_seconds: 0,
6599 change_nanoseconds: 0,
6600 })
6601 }
6602 #[cfg(not(unix))]
6603 {
6604 Ok(UntrackedFile {
6605 kind: 3,
6606 len: 0,
6607 modified: None,
6608 created: metadata.created().ok(),
6609 readonly: metadata.permissions().readonly(),
6610 symlink_target: None,
6611 })
6612 }
6613}
6614
6615fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
6616 let metadata = std::fs::symlink_metadata(path)
6617 .map_err(|e| spar_err!("could not inspect untracked file {}: {e}", path.display()))?;
6618 let kind = if metadata.file_type().is_symlink() {
6619 2
6620 } else if metadata.is_file() {
6621 1
6622 } else {
6623 bail!(
6624 "untracked path {} is not a regular file or symlink",
6625 path.display()
6626 );
6627 };
6628 let symlink_target = if kind == 2 {
6629 let target = std::fs::read_link(path)
6630 .map_err(|e| spar_err!("could not read untracked symlink {}: {e}", path.display()))?;
6631 Some(os_str_bytes(target.as_os_str())?)
6632 } else {
6633 None
6634 };
6635 #[cfg(unix)]
6636 {
6637 use std::os::unix::fs::MetadataExt;
6638 Ok(UntrackedFile {
6639 kind,
6640 len: metadata.len(),
6641 modified: metadata.modified().ok(),
6642 created: metadata.created().ok(),
6643 readonly: metadata.permissions().readonly(),
6644 symlink_target,
6645 device: metadata.dev(),
6646 inode: metadata.ino(),
6647 mode: metadata.mode(),
6648 change_seconds: metadata.ctime(),
6649 change_nanoseconds: metadata.ctime_nsec(),
6650 })
6651 }
6652 #[cfg(not(unix))]
6653 {
6654 Ok(UntrackedFile {
6655 kind,
6656 len: metadata.len(),
6657 modified: metadata.modified().ok(),
6658 created: metadata.created().ok(),
6659 readonly: metadata.permissions().readonly(),
6660 symlink_target,
6661 })
6662 }
6663}
6664
6665#[cfg(unix)]
6666fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6667 use std::os::unix::ffi::OsStrExt;
6668 Ok(value.as_bytes().to_vec())
6669}
6670
6671#[cfg(not(unix))]
6672fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6673 value
6674 .to_str()
6675 .map(|value| value.as_bytes().to_vec())
6676 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
6677}
6678
6679#[cfg(unix)]
6680fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6681 use std::os::unix::fs::MetadataExt;
6682 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
6683}
6684
6685#[cfg(not(unix))]
6686fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6687 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
6688}
6689
6690#[derive(Debug, Clone, serde::Serialize, Deserialize)]
6691pub struct BranchRecord {
6692 pub kind: String,
6693 pub number: i64,
6694}
6695
6696pub fn review_ref(number: i64) -> String {
6699 format!("refs/spar/pr-{number}")
6700}
6701
6702pub fn is_finished(state: &str) -> bool {
6703 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
6704}
6705
6706pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
6713 if let Some(parent) = path.parent() {
6714 std::fs::create_dir_all(parent)
6715 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6716 }
6717 let tmp = path.with_extension(format!(
6720 "{}.tmp",
6721 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
6722 ));
6723 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
6724 std::fs::rename(&tmp, path)
6725 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
6726 Ok(())
6727}
6728
6729pub fn has_heading(text: &str, title: &str) -> bool {
6736 let wanted = title.trim().to_lowercase();
6737 if wanted.is_empty() {
6738 return false;
6739 }
6740 text.lines()
6741 .filter_map(|line| line.trim().strip_prefix("## "))
6742 .any(|heading| heading.trim().to_lowercase() == wanted)
6743}
6744
6745pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
6748 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
6749}
6750
6751#[derive(Deserialize)]
6753#[serde(rename_all = "camelCase")]
6754pub struct LinkedPrRow {
6755 number: i64,
6756 #[serde(default)]
6757 url: String,
6758 #[serde(default)]
6759 title: String,
6760 #[serde(default)]
6761 closing_issues_references: Vec<IssueRef>,
6762}
6763
6764pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
6771 serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
6772}
6773
6774pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
6776 rows.iter()
6777 .find(|row| {
6778 row.closing_issues_references
6779 .iter()
6780 .any(|linked| linked.number == issue)
6781 })
6782 .map(|row| PrRef {
6783 number: row.number,
6784 url: row.url.clone(),
6785 title: row.title.clone(),
6786 })
6787}
6788
6789pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
6791 pick_linked_pr(&linked_pr_rows(json)?, issue)
6792}
6793
6794fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
6801 if text.trim().is_empty() {
6802 return Err(spar_err!("GitHub returned no comment data"));
6803 }
6804 let mut out = Vec::new();
6805 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6806 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
6807 Value::Array(items) => out.extend(items),
6808 _ => return Err(spar_err!("unexpected non-array comment page")),
6809 }
6810 }
6811 Ok(out)
6812}
6813
6814pub fn parse_comment_pages(text: &str) -> Vec<Value> {
6815 let mut out = Vec::new();
6816 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6817 match value {
6818 Ok(Value::Array(items)) => out.extend(items),
6819 Ok(other) => out.push(other),
6820 Err(_) => break,
6821 }
6822 }
6823 out
6824}
6825
6826pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
6834 body.trim_start().starts_with(STATE_MARKER)
6835 && !viewer.trim().is_empty()
6836 && author.trim().eq_ignore_ascii_case(viewer.trim())
6837 && parse_state_comment(body).is_some()
6838}
6839
6840pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
6843 let marker = body.find(STATE_MARKER)?;
6844 let start = body[marker..].find('{')? + marker;
6845 let end = body.rfind('}')?;
6846 if end <= start {
6847 return None;
6848 }
6849 match serde_json::from_str(&body[start..=end]) {
6850 Ok(state) => Some(state),
6851 Err(_) => {
6852 logdim!("found a spar state comment but could not parse it");
6853 None
6854 }
6855 }
6856}
6857
6858fn choose_state_for_head(
6859 candidates: Vec<PersistedState>,
6860 actual_head: &str,
6861) -> Option<PersistedState> {
6862 let matching: Vec<PersistedState> = candidates
6863 .iter()
6864 .filter(|state| state.pr_head == actual_head)
6865 .cloned()
6866 .collect();
6867 if !matching.is_empty() {
6868 return newest_state(matching);
6869 }
6870 newest_state(candidates)
6871}
6872
6873fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
6874 candidates.into_iter().reduce(|best, candidate| {
6875 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
6876 candidate
6877 } else {
6878 best
6883 }
6884 })
6885}
6886
6887pub fn self_binary() -> Result<PathBuf> {
6893 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
6894 let path = PathBuf::from(path);
6895 if proc::is_executable(&path) {
6896 return Ok(path);
6897 }
6898 bail!(
6899 "SPAR_SELF_BIN is set to {}, which is not executable",
6900 path.display()
6901 );
6902 }
6903 std::env::current_exe()
6904 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
6905}
6906
6907fn bool_env(value: bool) -> &'static str {
6908 if value {
6909 "1"
6910 } else {
6911 "0"
6912 }
6913}
6914
6915pub fn sh_quote(text: &str) -> String {
6918 format!("'{}'", text.replace('\'', r"'\''"))
6919}
6920
6921pub fn style_from_env() -> Style {
6924 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
6925 Style {
6926 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
6927 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
6928 ..Style::permissive()
6929 }
6930}
6931
6932#[cfg(test)]
6933mod tests {
6934 use super::*;
6935 use crate::config::StateStore;
6936 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
6937 use std::process::Command;
6938
6939 fn repo_for_titles() -> Repo {
6940 Repo {
6941 root: PathBuf::from("/nonexistent"),
6942 style: Style::default(),
6943 branch_prefix: String::new(),
6944 state_store: StateStore::Local,
6945 followups: crate::config::Followups::Issues,
6946 drafts: Drafts::Never,
6947 viewer: OnceLock::new(),
6948 checkpoints: Mutex::new(BTreeMap::new()),
6949 writes: WriteStats::default(),
6950 }
6951 }
6952
6953 #[test]
6954 fn write_results_accumulate_for_the_run() {
6955 let repo = repo_for_titles();
6956
6957 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
6958 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
6959
6960 assert_eq!(
6961 WriteSummary {
6962 attempted: 2,
6963 failed: 1,
6964 },
6965 repo.write_summary()
6966 );
6967 }
6968
6969 #[test]
6970 fn only_failed_write_preflights_join_the_summary() {
6971 let repo = repo_for_titles();
6972
6973 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
6974 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
6975
6976 assert_eq!(
6977 WriteSummary {
6978 attempted: 1,
6979 failed: 1,
6980 },
6981 repo.write_summary()
6982 );
6983 }
6984
6985 #[test]
6986 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
6987 let repo = repo_for_titles();
6988
6989 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
6990 assert_eq!(
6991 WriteSummary {
6992 attempted: 1,
6993 failed: 1,
6994 },
6995 repo.write_summary()
6996 );
6997 }
6998
6999 #[test]
7000 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7001 let mut repo = repo_for_titles();
7002 repo.followups = Followups::Local;
7003
7004 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7005 assert_eq!(WriteSummary::default(), repo.write_summary());
7006 }
7007
7008 #[test]
7009 fn a_failed_remote_state_read_stops_before_state_mutation() {
7010 let root = std::env::temp_dir().join(format!(
7011 "spar-state-preflight-{}-{}",
7012 std::process::id(),
7013 std::time::SystemTime::now()
7014 .duration_since(std::time::UNIX_EPOCH)
7015 .unwrap()
7016 .as_nanos()
7017 ));
7018 std::fs::create_dir_all(&root).unwrap();
7019 let _fixture = ReviewFixture { root: root.clone() };
7020 let mut repo = repo_for_titles();
7021 repo.root = root;
7022 repo.state_store = StateStore::Both;
7023 let state = PersistedState {
7024 version: 1,
7025 checkpoint: 4,
7026 round: 2,
7027 next_actor: "a".into(),
7028 status: Status::Pending,
7029 pr_head: "abc123".into(),
7030 ledger: Ledger::new(),
7031 filed: Vec::new(),
7032 open_findings: Vec::new(),
7033 disputes: Vec::new(),
7034 noted: Vec::new(),
7035 };
7036
7037 let error = repo
7038 .write_state_after_remote_read(
7039 7,
7040 &state,
7041 Err(crate::error::SparError::new("state comments unavailable")),
7042 )
7043 .unwrap_err();
7044
7045 assert!(error.to_string().contains("state comments unavailable"));
7046 assert!(!repo.state_path(7).exists());
7047 assert_eq!(0, repo.remembered_checkpoint(7));
7048 assert_eq!(
7049 WriteSummary {
7050 attempted: 1,
7051 failed: 1,
7052 },
7053 repo.write_summary()
7054 );
7055 }
7056
7057 #[test]
7058 fn only_known_build_and_cache_directories_are_generated_artifacts() {
7059 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7060 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7061 assert!(is_generated_artifact(Path::new(
7062 "package/node_modules/dependency/file.js"
7063 )));
7064 assert!(!is_generated_artifact(Path::new(
7065 "distribution/required-package.js"
7066 )));
7067 assert!(!is_generated_artifact(Path::new(
7068 "generated/required-fixture.txt"
7069 )));
7070 assert!(!is_generated_artifact(Path::new("local.env")));
7071 }
7072
7073 struct ReviewFixture {
7074 root: PathBuf,
7075 }
7076
7077 impl Drop for ReviewFixture {
7078 fn drop(&mut self) {
7079 let _ = std::fs::remove_dir_all(&self.root);
7080 }
7081 }
7082
7083 fn test_git(cwd: &Path, args: &[&str]) -> String {
7084 let output = Command::new("git")
7085 .args(args)
7086 .current_dir(cwd)
7087 .output()
7088 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7089 assert!(
7090 output.status.success(),
7091 "git {args:?} failed: {}",
7092 String::from_utf8_lossy(&output.stderr)
7093 );
7094 String::from_utf8_lossy(&output.stdout).into_owned()
7095 }
7096
7097 fn review_fixture(
7098 tag: &str,
7099 number: i64,
7100 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7101 use std::sync::atomic::{AtomicU32, Ordering};
7102 static NEXT: AtomicU32 = AtomicU32::new(0);
7103 let id = NEXT.fetch_add(1, Ordering::Relaxed);
7104 let root =
7105 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7106 let origin = root.join("origin.git");
7107 let work = root.join("work");
7108 std::fs::create_dir_all(&origin).unwrap();
7109 std::fs::create_dir_all(&work).unwrap();
7110 test_git(&origin, &["init", "--bare", "-b", "main"]);
7111 test_git(&work, &["init", "-b", "main"]);
7112 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7113 test_git(&work, &["config", "user.name", "spar test"]);
7114 test_git(&work, &["config", "commit.gpgsign", "false"]);
7115 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7116 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7117 std::fs::write(work.join("README.md"), "seed\n").unwrap();
7118 std::fs::write(work.join("data.txt"), "old\n").unwrap();
7119 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7120 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7121 test_git(&work, &["add", "."]);
7122 test_git(&work, &["commit", "-m", "seed"]);
7123 test_git(
7124 &work,
7125 &["remote", "add", "origin", origin.to_str().unwrap()],
7126 );
7127 test_git(&work, &["push", "-u", "origin", "main"]);
7128 test_git(
7129 &work,
7130 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7131 );
7132 let cfg = crate::config::parse(
7133 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7134 )
7135 .unwrap();
7136 let repo = Repo::open(&work, &cfg).unwrap();
7137 let path = repo.worktree_for_pr_head(number).unwrap();
7138 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7139 (ReviewFixture { root }, repo, path, checkpoint)
7140 }
7141
7142 #[test]
7143 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7144 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7145
7146 repo.release_review_worktree_checked(901, &checkpoint)
7147 .unwrap();
7148
7149 assert!(!path.exists());
7150 }
7151
7152 #[test]
7153 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7154 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7155 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7156 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7157 test_git(&path, &["add", "recovery.txt"]);
7158 test_git(&path, &["commit", "-m", "recovery commit"]);
7159 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7160 test_git(&path, &["reset", "--hard", "main"]);
7161
7162 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7163 test_git(
7164 &path,
7165 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7166 );
7167 }
7168
7169 #[test]
7170 fn a_review_ref_reflog_only_commit_prevents_deletion() {
7171 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7172 let local_ref = review_ref(921);
7173 let original = test_git(&path, &["rev-parse", &local_ref]);
7174 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7175 let recovery = test_git(
7176 &path,
7177 &[
7178 "commit-tree",
7179 tree.trim(),
7180 "-p",
7181 original.trim(),
7182 "-m",
7183 "review ref recovery",
7184 ],
7185 );
7186 test_git(
7187 &path,
7188 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7189 );
7190 test_git(
7191 &path,
7192 &["update-ref", &local_ref, original.trim(), recovery.trim()],
7193 );
7194
7195 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7196 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7197 test_git(
7198 &path,
7199 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7200 );
7201 }
7202
7203 #[test]
7204 fn an_unpublished_commit_message_draft_is_recoverable() {
7205 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7206 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7207 let git_dir = if raw.is_absolute() {
7208 raw
7209 } else {
7210 path.join(raw)
7211 };
7212 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7213
7214 assert!(repository_has_recoverable_work(&path, true).unwrap());
7215 assert_eq!(
7216 "unique recovery draft\n",
7217 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7218 );
7219 }
7220
7221 #[test]
7222 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7223 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7224 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7225
7226 let error = repo
7227 .release_review_worktree_checked(902, &checkpoint)
7228 .unwrap_err();
7229
7230 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7231 assert!(error.to_string().contains("kept for recovery"), "{error}");
7232 assert_eq!(
7233 "recover me\n",
7234 std::fs::read_to_string(path.join("README.md")).unwrap()
7235 );
7236 repo.release_review_worktree(902);
7237 }
7238
7239 #[test]
7240 fn a_review_commit_is_retained_after_a_checked_read() {
7241 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7242 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7243 test_git(&path, &["add", "review-note.txt"]);
7244 test_git(&path, &["commit", "-m", "local review recovery"]);
7245 let head = test_git(&path, &["rev-parse", "HEAD"]);
7246
7247 let error = repo
7248 .release_review_worktree_checked(903, &checkpoint)
7249 .unwrap_err();
7250
7251 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7252 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7253 assert_eq!(
7254 "recover me\n",
7255 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7256 );
7257 repo.release_review_worktree(903);
7258 }
7259
7260 #[test]
7261 fn an_ignored_review_file_is_retained_after_a_checked_read() {
7262 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7263 std::fs::create_dir_all(path.join("generated")).unwrap();
7264 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7265
7266 let error = repo
7267 .release_review_worktree_checked(904, &checkpoint)
7268 .unwrap_err();
7269
7270 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7271 assert_eq!(
7272 "recover me\n",
7273 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7274 );
7275 repo.release_review_worktree(904);
7276 }
7277
7278 #[test]
7279 fn a_preexisting_ignored_review_file_change_is_retained() {
7280 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7281 std::fs::create_dir_all(path.join("generated")).unwrap();
7282 let ignored = path.join("generated/recovery.txt");
7283 std::fs::write(&ignored, "before\n").unwrap();
7284 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7285 std::fs::write(&ignored, "after!\n").unwrap();
7286
7287 let error = repo
7288 .release_review_worktree_checked(905, &checkpoint)
7289 .unwrap_err();
7290
7291 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7292 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7293 repo.release_review_worktree(905);
7294 }
7295
7296 #[test]
7297 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7298 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7299 std::fs::create_dir_all(path.join("generated")).unwrap();
7300 let ignored = path.join("generated/recovery.txt");
7301 std::fs::write(&ignored, "keep me\n").unwrap();
7302 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7303
7304 let error = repo
7305 .release_review_worktree_checked(906, &checkpoint)
7306 .unwrap_err();
7307
7308 assert!(error.to_string().contains("recoverable"), "{error}");
7309 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7310 }
7311
7312 #[test]
7313 fn overwriting_a_preexisting_untracked_file_is_detected() {
7314 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7315 let untracked = path.join("notes.txt");
7316 std::fs::write(&untracked, "before\n").unwrap();
7317 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7318 std::fs::write(&untracked, "after!\n").unwrap();
7319
7320 let error = repo
7321 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7322 .unwrap_err();
7323
7324 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7325 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7326 }
7327
7328 #[test]
7329 fn an_assume_unchanged_edit_is_detected() {
7330 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7331 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7332 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7333
7334 let error = repo
7335 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7336 .unwrap_err();
7337
7338 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7339 assert_eq!(
7340 "hidden\n",
7341 std::fs::read_to_string(path.join("README.md")).unwrap()
7342 );
7343 }
7344
7345 #[test]
7346 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7347 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7348 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7349 test_git(&path, &["add", "README.md"]);
7350 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7351
7352 let error = repo
7353 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7354 .unwrap_err();
7355
7356 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7357 assert_eq!(
7358 b"seed\r\n",
7359 std::fs::read(path.join("README.md")).unwrap().as_slice()
7360 );
7361 }
7362
7363 #[cfg(unix)]
7364 #[test]
7365 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7366 use std::os::unix::fs::PermissionsExt;
7367
7368 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7369 test_git(&path, &["config", "core.filemode", "false"]);
7370 let readme = path.join("README.md");
7371 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7372 permissions.set_mode(0o755);
7373 std::fs::set_permissions(&readme, permissions).unwrap();
7374 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7375
7376 let error = repo
7377 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7378 .unwrap_err();
7379
7380 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7381 assert_eq!(
7382 0o755,
7383 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7384 );
7385 }
7386
7387 #[test]
7388 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7389 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7390 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7391 test_git(&path, &["add", ".gitattributes"]);
7392 test_git(&path, &["commit", "-m", "select data filter"]);
7393 let baseline = repo.worktree_baseline(&path).unwrap();
7394 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7395
7396 assert!(repo
7397 .commit_pending_changes(&path, &baseline, "change data", "change data")
7398 .unwrap());
7399 let error = repo
7400 .refuse_unrepresented_tracked_changes(&path, &baseline)
7401 .unwrap_err();
7402
7403 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7404 assert_eq!(
7405 "secret: recover me\nnew\n",
7406 std::fs::read_to_string(path.join("data.txt")).unwrap()
7407 );
7408 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7409 }
7410
7411 #[test]
7412 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7413 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7414 std::fs::create_dir_all(path.join("target")).unwrap();
7415 let untracked = path.join("target/user.yaml");
7416 std::fs::write(&untracked, "user data\n").unwrap();
7417 let baseline = repo.worktree_baseline(&path).unwrap();
7418 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7419
7420 assert!(repo
7421 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7422 .unwrap());
7423
7424 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
7425 assert_eq!(
7426 "?? target/user.yaml\n",
7427 test_git(&path, &["status", "--short", "--untracked-files=all"])
7428 );
7429 assert!(test_git(
7430 &path,
7431 &[
7432 "ls-tree",
7433 "-r",
7434 "--name-only",
7435 "HEAD",
7436 "--",
7437 "target/user.yaml"
7438 ]
7439 )
7440 .is_empty());
7441 }
7442
7443 #[test]
7444 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
7445 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
7446 std::fs::create_dir_all(path.join("target")).unwrap();
7447 let untracked = path.join("target/user.yaml");
7448 std::fs::write(&untracked, "before\n").unwrap();
7449 let baseline = repo.worktree_baseline(&path).unwrap();
7450 let before = test_git(&path, &["rev-parse", "HEAD"]);
7451 std::fs::write(&untracked, "after\n").unwrap();
7452 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7453
7454 let error = repo
7455 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7456 .unwrap_err();
7457
7458 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7459 assert!(error.to_string().contains("target/user.yaml"), "{error}");
7460 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7461 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
7462 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
7463 }
7464
7465 #[test]
7466 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
7467 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
7468 let baseline = repo.worktree_baseline(&path).unwrap();
7469 std::fs::create_dir_all(path.join("target")).unwrap();
7470 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
7471
7472 assert!(repo
7473 .commit_pending_changes(&path, &baseline, "add file", "add file")
7474 .unwrap());
7475
7476 assert_eq!(
7477 "new file\n",
7478 test_git(&path, &["show", "HEAD:target/new.txt"])
7479 );
7480 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7481 }
7482
7483 #[test]
7484 fn deleting_existing_ignored_work_stops_a_managed_commit() {
7485 let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7486 std::fs::create_dir_all(path.join("generated")).unwrap();
7487 let ignored = path.join("generated/keep.txt");
7488 std::fs::write(&ignored, "user data\n").unwrap();
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::remove_file(&ignored).unwrap();
7493
7494 let error = repo
7495 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7496 .unwrap_err();
7497
7498 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7499 assert!(error.to_string().contains("existing untracked"), "{error}");
7500 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7501 assert_eq!(
7502 "tracked change\n",
7503 std::fs::read_to_string(path.join("README.md")).unwrap()
7504 );
7505 }
7506
7507 #[test]
7508 fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
7509 let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
7510 let baseline = repo.worktree_baseline(&path).unwrap();
7511 let before = test_git(&path, &["rev-parse", "HEAD"]);
7512 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7513 std::fs::create_dir_all(path.join("generated")).unwrap();
7514 let ignored = path.join("generated/recovery.txt");
7515 std::fs::write(&ignored, "keep me\n").unwrap();
7516
7517 let error = repo
7518 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7519 .unwrap_err();
7520
7521 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7522 assert!(error.to_string().contains("recovery.txt"), "{error}");
7523 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7524 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7525 assert!(test_git(&path, &["status", "--porcelain"])
7526 .lines()
7527 .any(|line| line == "M README.md"));
7528 }
7529
7530 #[test]
7531 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
7532 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
7533 test_git(&path, &["config", "core.autocrlf", "true"]);
7534 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7535 assert_eq!(
7536 test_git(&path, &["hash-object", "README.md"]).trim(),
7537 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7538 );
7539
7540 assert!(repository_has_recoverable_work(&path, true).unwrap());
7541 assert_eq!(
7542 "seed\n",
7543 std::fs::read_to_string(path.join("README.md")).unwrap()
7544 );
7545 }
7546
7547 #[test]
7548 fn autocrlf_input_overrides_a_crlf_core_eol() {
7549 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
7550 test_git(&path, &["config", "core.autocrlf", "input"]);
7551 test_git(&path, &["config", "core.eol", "crlf"]);
7552 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7553 assert_eq!(
7554 test_git(&path, &["hash-object", "README.md"]).trim(),
7555 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7556 );
7557
7558 assert!(repository_has_recoverable_work(&path, true).unwrap());
7559 assert_eq!(
7560 b"seed\r\n",
7561 std::fs::read(path.join("README.md")).unwrap().as_slice()
7562 );
7563 }
7564
7565 #[cfg(unix)]
7566 #[test]
7567 fn a_non_executable_permission_change_is_recoverable() {
7568 use std::os::unix::fs::PermissionsExt;
7569
7570 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
7571 let readme = path.join("README.md");
7572 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7573 permissions.set_mode(0o600);
7574 std::fs::set_permissions(&readme, permissions).unwrap();
7575 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7576
7577 let error = repo
7578 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7579 .unwrap_err();
7580
7581 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7582 assert!(repository_has_recoverable_work(&path, true).unwrap());
7583 assert_eq!(
7584 0o600,
7585 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7586 );
7587 }
7588
7589 #[cfg(unix)]
7590 #[test]
7591 fn a_managed_commit_skips_signing_and_hooks() {
7592 use std::os::unix::fs::PermissionsExt;
7593
7594 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
7595 let common = common_git_dir(&path).unwrap();
7596 let hook = common.join("hooks/pre-commit");
7597 let marker = fixture.root.join("hook-ran");
7598 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
7599 std::fs::write(
7600 &hook,
7601 format!(
7602 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
7603 sh_quote(marker.to_str().unwrap())
7604 ),
7605 )
7606 .unwrap();
7607 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
7608 permissions.set_mode(0o755);
7609 std::fs::set_permissions(&hook, permissions).unwrap();
7610 test_git(&path, &["config", "commit.gpgsign", "true"]);
7611 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
7612 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
7613 test_git(&path, &["add", "managed.txt"]);
7614
7615 repo.commit_staged_changes(&path, "record managed change")
7616 .unwrap();
7617
7618 assert!(!marker.exists());
7619 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
7620 }
7621
7622 #[test]
7623 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
7624 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
7625 std::fs::write(
7626 path.join(".gitattributes"),
7627 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
7628 )
7629 .unwrap();
7630 test_git(&path, &["add", ".gitattributes"]);
7631 test_git(&path, &["commit", "-m", "select automatic text"]);
7632 test_git(&path, &["config", "core.autocrlf", "true"]);
7633
7634 assert!(repository_has_recoverable_work(&path, true).unwrap());
7635 }
7636
7637 #[test]
7638 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
7639 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
7640 std::fs::write(
7641 path.join(".gitattributes"),
7642 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
7643 )
7644 .unwrap();
7645 test_git(&path, &["add", ".gitattributes"]);
7646 test_git(&path, &["commit", "-m", "select ident expansion"]);
7647 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7648
7649 assert!(repository_has_recoverable_work(&path, true).unwrap());
7650 }
7651
7652 fn exclude_paths(repo: &Repo, lines: &[&str]) {
7655 use std::io::Write;
7656 let path = repo.root().join(".git").join("info").join("exclude");
7657 let mut file = std::fs::OpenOptions::new()
7658 .create(true)
7659 .append(true)
7660 .open(&path)
7661 .unwrap();
7662 for line in lines {
7663 writeln!(file, "{line}").unwrap();
7664 }
7665 }
7666
7667 #[test]
7668 fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
7669 let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
7670 exclude_paths(&repo, &["dist/"]);
7671 std::fs::create_dir_all(path.join("dist")).unwrap();
7672 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7673 let baseline = repo.worktree_baseline(&path).unwrap();
7674 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7675 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7676
7677 repo.refuse_new_ignored_files(&path, &baseline).unwrap();
7678 repo.refuse_changed_existing_untracked(&path, &baseline)
7679 .unwrap();
7680 }
7681
7682 #[test]
7683 fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
7684 let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
7685 exclude_paths(&repo, &["dist/", ".env.local"]);
7686 std::fs::create_dir_all(path.join("dist")).unwrap();
7687 let baseline = repo.worktree_baseline(&path).unwrap();
7688 std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
7689 std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
7690
7691 let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
7692
7693 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7694 assert!(error.to_string().contains(".env.local"), "{error}");
7695 }
7696
7697 #[test]
7698 fn a_read_only_inspection_may_rebuild_generated_output() {
7699 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
7700 exclude_paths(&repo, &["dist/"]);
7701 std::fs::create_dir_all(path.join("dist")).unwrap();
7702 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7703 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7704 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7705 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7706
7707 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7708 .unwrap();
7709 }
7710
7711 #[test]
7712 fn a_read_only_inspection_may_not_change_an_ignored_file_elsewhere() {
7713 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
7714 exclude_paths(&repo, &["dist/", ".env.local"]);
7715 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
7716 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7717 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
7718
7719 let error = repo
7720 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7721 .unwrap_err();
7722
7723 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7724 }
7725
7726 #[test]
7727 fn build_output_alone_does_not_keep_a_worktree() {
7728 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
7729 exclude_paths(&repo, &["target/", "dist/"]);
7730 std::fs::create_dir_all(path.join("target/debug")).unwrap();
7731 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7732 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
7733 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
7734
7735 assert!(!repository_has_recoverable_work(&path, true).unwrap());
7736 repo.release_review_worktree(933);
7737
7738 assert!(!path.exists());
7739 }
7740
7741 #[test]
7742 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
7743 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
7744 exclude_paths(&repo, &["target/", ".env.local"]);
7745 std::fs::create_dir_all(path.join("target/debug")).unwrap();
7746 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7747 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
7748
7749 assert!(repository_has_recoverable_work(&path, true).unwrap());
7750 repo.release_review_worktree(934);
7751
7752 assert_eq!(
7753 "TOKEN=keep me\n",
7754 std::fs::read_to_string(path.join(".env.local")).unwrap()
7755 );
7756 }
7757
7758 #[test]
7759 fn a_repository_nested_in_build_output_keeps_a_worktree() {
7760 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
7761 exclude_paths(&repo, &["node_modules/"]);
7762 let nested = path.join("node_modules/local-dep");
7763 std::fs::create_dir_all(&nested).unwrap();
7764 test_git(&nested, &["init"]);
7765 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
7766
7767 assert!(repository_has_recoverable_work(&path, true).unwrap());
7768 repo.release_review_worktree(935);
7769
7770 assert!(nested.join(".git").exists());
7771 }
7772
7773 #[test]
7774 fn an_ordinary_untracked_file_keeps_a_worktree() {
7775 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
7776 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
7777
7778 assert!(repository_has_recoverable_work(&path, true).unwrap());
7779 repo.release_review_worktree(936);
7780
7781 assert_eq!(
7782 "somebody's notes\n",
7783 std::fs::read_to_string(path.join("notes.md")).unwrap()
7784 );
7785 }
7786
7787 #[test]
7788 fn a_legacy_crlf_checkout_is_retained_conservatively() {
7789 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
7790 std::fs::write(
7791 path.join(".gitattributes"),
7792 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
7793 )
7794 .unwrap();
7795 test_git(&path, &["add", ".gitattributes"]);
7796 test_git(&path, &["commit", "-m", "select legacy line endings"]);
7797
7798 assert!(repository_has_recoverable_work(&path, true).unwrap());
7799 }
7800
7801 #[test]
7802 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
7803 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
7804 let nested = path.join("tracked");
7805 std::fs::create_dir_all(&nested).unwrap();
7806 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
7807 test_git(&path, &["add", "tracked/seed.txt"]);
7808 test_git(&path, &["commit", "-m", "add tracked directory"]);
7809 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7810 test_git(&nested, &["init"]);
7811
7812 let error = repo
7813 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7814 .unwrap_err();
7815
7816 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7817 assert!(error.to_string().contains("Git entry"), "{error}");
7818 assert!(nested.join(".git").exists());
7819 }
7820
7821 #[test]
7826 fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
7827 let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
7828 exclude_paths(&repo, &["target/"]);
7829 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7830 let repro = path.join("target/pr918-repro");
7831 std::fs::create_dir_all(&repro).unwrap();
7832 test_git(&repro, &["init"]);
7833 std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
7834
7835 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7836 .unwrap();
7837
7838 assert!(repository_has_recoverable_work(&path, true).unwrap());
7839 assert!(repro.join(".git").exists());
7840 }
7841
7842 #[test]
7846 fn a_repository_inside_a_nested_checkout_is_left_alone() {
7847 let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
7848 exclude_paths(&repo, &["target/"]);
7849 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7850 let repro = path.join("target/repro");
7851 let vendored = repro.join("vendor/dep");
7852 std::fs::create_dir_all(&vendored).unwrap();
7853 test_git(&repro, &["init"]);
7854 test_git(&vendored, &["init"]);
7855
7856 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7857 .unwrap();
7858
7859 assert!(vendored.join(".git").exists());
7860 }
7861
7862 #[test]
7863 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
7864 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
7865
7866 let state = ignored_untracked_state(repo.root()).unwrap();
7867
7868 let relative = path.strip_prefix(repo.root()).unwrap();
7869 assert!(
7870 state.files.contains_key(relative),
7871 "{:?}",
7872 state.files.keys().collect::<Vec<_>>()
7873 );
7874 assert!(state.is_ignored(relative));
7875 }
7876
7877 #[test]
7878 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
7879 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
7880 let baseline = repo.worktree_baseline(repo.root()).unwrap();
7881 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
7882 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
7883
7884 repo.refuse_new_ignored_files(repo.root(), &baseline)
7885 .unwrap();
7886 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
7887 .unwrap();
7888 }
7889
7890 #[test]
7891 fn deleting_a_resident_worktree_during_a_call_is_refused() {
7892 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
7893 let baseline = repo.worktree_baseline(repo.root()).unwrap();
7894 std::fs::remove_dir_all(&path).unwrap();
7895
7896 let error = repo
7897 .refuse_new_ignored_files(repo.root(), &baseline)
7898 .unwrap_err();
7899
7900 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7901 assert!(error.to_string().contains("review-932"), "{error}");
7902 }
7903
7904 #[test]
7905 fn a_nested_repository_record_is_read_as_a_plain_path() {
7906 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
7907 assert_eq!(Path::new("vendor/checkout"), path);
7908 assert!(nested);
7909
7910 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
7911 assert_eq!(Path::new("vendor/notes.txt"), path);
7912 assert!(!nested);
7913
7914 assert!(untracked_record(b"/", "untracked").is_err());
7915 }
7916
7917 #[cfg(unix)]
7918 #[test]
7919 fn a_non_utf8_git_path_is_preserved_without_loss() {
7920 use std::os::unix::ffi::OsStrExt;
7921
7922 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
7923
7924 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
7925 }
7926
7927 #[test]
7928 fn guarded_merge_pins_the_reviewed_head() {
7929 let args = merge_pr_args("36", Some("abc123"), true);
7930 assert_eq!(
7931 vec![
7932 "pr",
7933 "merge",
7934 "36",
7935 "--squash",
7936 "--delete-branch",
7937 "--match-head-commit",
7938 "abc123"
7939 ],
7940 args
7941 );
7942 }
7943
7944 #[test]
7945 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
7946 let pr = PrRef {
7947 number: 7,
7948 url: "https://example.test/pull/7".into(),
7949 title: "part one".into(),
7950 };
7951 let result = reconcile_pr_creation(
7952 "split-34-1",
7953 Err(crate::error::SparError::new("connection lost")),
7954 Ok(Some(pr)),
7955 )
7956 .unwrap();
7957 assert_eq!(7, result.number);
7958 }
7959
7960 #[test]
7961 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
7962 let error = reconcile_pr_creation(
7963 "split-34-1",
7964 Err(crate::error::SparError::new("permission denied")),
7965 Ok(None),
7966 )
7967 .unwrap_err();
7968 assert!(error.to_string().contains("permission denied"), "{error}");
7969 }
7970
7971 #[test]
7972 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
7973 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
7974 assert!(pr_for_base(text, "split-34-2", "split-34-1")
7975 .unwrap()
7976 .is_none());
7977 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
7978 assert_eq!(7, found.number);
7979 }
7980
7981 #[test]
7982 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
7983 let result = reconcile_comment_post(
7984 34,
7985 "the summary",
7986 crate::error::SparError::new("connection lost"),
7987 Ok(vec![serde_json::json!({"body": "the summary"})]),
7988 );
7989 assert!(result.is_ok(), "{result:?}");
7990 }
7991
7992 #[test]
7993 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
7994 let error = reconcile_comment_post(
7995 34,
7996 "the summary",
7997 crate::error::SparError::new("connection lost"),
7998 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
7999 )
8000 .unwrap_err();
8001 assert_eq!("connection lost", error.to_string());
8002 }
8003
8004 #[test]
8005 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
8006 let error = reconcile_comment_post(
8007 34,
8008 "the summary",
8009 crate::error::SparError::new("connection lost"),
8010 Err(crate::error::SparError::new("comments unavailable")),
8011 )
8012 .unwrap_err();
8013 assert!(
8014 error.to_string().contains("could not be verified"),
8015 "{error}"
8016 );
8017 assert!(
8018 error.to_string().contains("comments unavailable"),
8019 "{error}"
8020 );
8021 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8022 assert!(!error.worth_retrying());
8023 }
8024
8025 #[test]
8026 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
8027 let result = reconcile_issue_edit(
8028 34,
8029 "wanted body",
8030 crate::error::SparError::new("connection lost"),
8031 Ok("wanted body".to_string()),
8032 );
8033 assert!(result.is_ok(), "{result:?}");
8034 }
8035
8036 #[test]
8037 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
8038 let error = reconcile_issue_edit(
8039 34,
8040 "wanted body",
8041 crate::error::SparError::new("connection lost"),
8042 Err(crate::error::SparError::new("issue unavailable")),
8043 )
8044 .unwrap_err();
8045 assert!(
8046 error.to_string().contains("could not be verified"),
8047 "{error}"
8048 );
8049 assert!(error.to_string().contains("issue unavailable"), "{error}");
8050 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8051 assert!(!error.worth_retrying());
8052 }
8053
8054 #[test]
8055 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
8056 let found = ExistingIssue {
8057 number: 101,
8058 url: "https://example.test/issues/101".into(),
8059 title: "child".into(),
8060 body: "body".into(),
8061 open: true,
8062 };
8063 let url = reconcile_issue_creation(
8064 "child",
8065 Err(crate::error::SparError::new("connection lost")),
8066 Ok(Some(found)),
8067 )
8068 .unwrap();
8069 assert_eq!("https://example.test/issues/101", url);
8070 }
8071
8072 #[test]
8073 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8074 let error = reconcile_issue_creation(
8075 "child",
8076 Err(crate::error::SparError::new("permission denied")),
8077 Ok(None),
8078 )
8079 .unwrap_err();
8080 assert!(error.to_string().contains("permission denied"), "{error}");
8081 }
8082
8083 #[test]
8084 fn an_unverifiable_issue_creation_is_marked_uncertain() {
8085 let error = reconcile_issue_creation(
8086 "child",
8087 Err(crate::error::SparError::new("connection lost")),
8088 Err(crate::error::SparError::new("issues unavailable")),
8089 )
8090 .unwrap_err();
8091 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8092 assert!(!error.worth_retrying());
8093 }
8094
8095 #[test]
8096 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8097 let result = reconcile_failed_split_push(
8098 "split-34-1",
8099 crate::error::SparError::new("connection lost"),
8100 Ok("abc123\n".into()),
8101 Ok("abc123\trefs/heads/split-34-1\n".into()),
8102 );
8103 assert!(result.is_ok(), "{result:?}");
8104 }
8105
8106 #[test]
8107 fn a_split_push_collision_is_definite_and_never_overwrites() {
8108 let error = reconcile_failed_split_push(
8109 "split-34-1",
8110 crate::error::SparError::new("lease rejected"),
8111 Ok("abc123\n".into()),
8112 Ok("def456\trefs/heads/split-34-1\n".into()),
8113 )
8114 .unwrap_err();
8115 assert!(!error.retain_worktree());
8116 assert!(
8117 error.to_string().contains("Nothing was overwritten"),
8118 "{error}"
8119 );
8120 }
8121
8122 #[test]
8123 fn an_unreadable_split_push_result_keeps_the_worktree() {
8124 let error = reconcile_failed_split_push(
8125 "split-34-1",
8126 crate::error::SparError::new("connection lost"),
8127 Ok("abc123\n".into()),
8128 Err(crate::error::SparError::new("origin unavailable")),
8129 )
8130 .unwrap_err();
8131 assert!(error.retain_worktree());
8132 assert!(error.to_string().contains("could not confirm"), "{error}");
8133 }
8134
8135 #[test]
8139 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8140 let repo = repo_for_titles();
8141 for raw in [
8142 "Retry loop spins \u{2014} Retry-After parses to zero",
8143 "plain title",
8144 " spread over\nlines ",
8145 "\u{1F916} Generated with something",
8146 &format!("a \u{2014} {}", "very long title ".repeat(20)),
8147 &"x".repeat(300),
8148 &format!("{} \u{2014} end", "y".repeat(88)),
8149 &{
8154 let tail = "a\u{2014}b c\u{2014}d";
8155 let pad = Style::default().max_title_chars - tail.chars().count();
8156 format!("{}{tail}", "w".repeat(pad))
8157 },
8158 ] {
8159 let once = repo.clean_title(raw).unwrap();
8160 let twice = repo.clean_title(&once).unwrap();
8161 assert_eq!(once, twice, "not idempotent for {raw:?}");
8162 assert!(
8163 once.chars().count() <= repo.style.max_title_chars,
8164 "over budget: {once:?}"
8165 );
8166 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8167 }
8168 }
8169
8170 #[test]
8171 fn a_title_with_an_em_dash_survives_as_readable_text() {
8172 let repo = repo_for_titles();
8173 assert_eq!(
8174 "Retry loop spins, Retry-After parses to zero",
8175 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8176 .unwrap()
8177 );
8178 }
8179
8180 #[test]
8181 fn sh_quote_survives_a_quote() {
8182 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
8183 }
8184
8185 #[test]
8186 fn sh_quote_wraps_a_space() {
8187 assert_eq!(
8188 "'/Applications/My App/spar'",
8189 sh_quote("/Applications/My App/spar")
8190 );
8191 }
8192
8193 #[test]
8194 fn finished_states_are_recognised_case_insensitively() {
8195 assert!(is_finished("MERGED"));
8196 assert!(is_finished("closed"));
8197 assert!(!is_finished("OPEN"));
8198 assert!(!is_finished(""));
8199 }
8200
8201 fn state() -> PersistedState {
8202 PersistedState {
8203 version: 1,
8204 checkpoint: 0,
8205 round: 4,
8206 next_actor: "codex".into(),
8207 status: Status::Pending,
8208 pr_head: "abc123".into(),
8209 ledger: Ledger::new(),
8210 filed: vec![],
8211 open_findings: vec![Finding {
8212 severity: Severity::Blocking,
8213 title: "Unchecked error".into(),
8214 detail: "the failure is discarded".into(),
8215 file: "src/a.rs:12".into(),
8216 ..Finding::default()
8217 }],
8218 disputes: vec![Dispute {
8219 title: "Retry limit".into(),
8220 file: "src/net.rs".into(),
8221 reasoning: "the caller already bounds it".into(),
8222 }],
8223 noted: vec![Finding {
8224 severity: Severity::NonBlocking,
8225 title: "Timeout is fixed".into(),
8226 file: "src/config.rs".into(),
8227 ..Finding::default()
8228 }],
8229 }
8230 }
8231
8232 #[test]
8233 fn a_state_comment_round_trips() {
8234 let body = format!(
8235 "{STATE_MARKER}\n{}\n-->",
8236 serde_json::to_string(&state()).unwrap()
8237 );
8238 let back = parse_state_comment(&body).unwrap();
8239 assert_eq!(4, back.round);
8240 assert_eq!("codex", back.next_actor);
8241 assert_eq!("abc123", back.pr_head);
8242 assert_eq!("Unchecked error", back.open_findings[0].title);
8243 assert_eq!("src/net.rs", back.disputes[0].file);
8244 assert_eq!("Timeout is fixed", back.noted[0].title);
8245 }
8246
8247 #[test]
8248 fn old_state_without_new_lists_still_parses() {
8249 let body = format!(
8250 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
8251 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8252 );
8253 let back = parse_state_comment(&body).expect("old state");
8254 assert!(back.open_findings.is_empty());
8255 assert!(back.disputes.is_empty());
8256 assert!(back.noted.is_empty());
8257 assert!(back.pr_head.is_empty());
8258 assert_eq!(0, back.checkpoint);
8259 }
8260
8261 #[test]
8262 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
8263 let mut local = state();
8264 local.pr_head = "old".into();
8265 local.round = 9;
8266 let mut remote = state();
8267 remote.pr_head = "current".into();
8268 remote.round = 4;
8269
8270 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8271 assert_eq!("current", chosen.pr_head);
8272 assert_eq!(4, chosen.round);
8273 }
8274
8275 #[test]
8276 fn checkpoint_order_breaks_same_round_ties() {
8277 let mut local = state();
8278 local.pr_head = "current".into();
8279 local.round = 4;
8280 local.checkpoint = 8;
8281 let mut remote = local.clone();
8282 remote.checkpoint = 7;
8283 remote.open_findings.clear();
8284
8285 let chosen = choose_state_for_head(vec![local], "current").unwrap();
8286 assert_eq!(8, chosen.checkpoint);
8287
8288 let mut local = state();
8289 local.pr_head = "current".into();
8290 local.round = 4;
8291 local.checkpoint = 8;
8292 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
8293 assert_eq!(8, chosen.checkpoint);
8294 }
8295
8296 #[test]
8297 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
8298 let mut local = state();
8299 local.pr_head = "current".into();
8300 local.round = 4;
8301 local.open_findings.push(Finding {
8302 title: "local checkpoint".into(),
8303 ..Finding::default()
8304 });
8305 let mut remote = state();
8306 remote.pr_head = "current".into();
8307 remote.round = 4;
8308
8309 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8310 assert_eq!(
8311 "local checkpoint",
8312 chosen.open_findings.last().unwrap().title
8313 );
8314 }
8315
8316 #[test]
8318 fn the_state_block_is_an_html_comment() {
8319 let body = format!(
8320 "{STATE_MARKER}\n{}\n-->",
8321 serde_json::to_string(&state()).unwrap()
8322 );
8323 assert!(body.starts_with("<!--"));
8324 assert!(body.trim_end().ends_with("-->"));
8325 assert!(!body[..body.find('{').unwrap()].contains("-->"));
8326 }
8327
8328 #[test]
8329 fn an_unrelated_json_block_is_not_state() {
8330 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
8331 }
8332
8333 #[test]
8340 fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
8341 let block = format!(
8342 "{STATE_MARKER}\n{}\n-->",
8343 serde_json::to_string(&state()).unwrap()
8344 );
8345 assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
8346 assert!(
8347 is_state_comment(&block, "Spar-Bot", "spar-bot"),
8348 "a login differs only in case"
8349 );
8350
8351 assert!(
8352 !is_state_comment(
8353 "why is there a `<!-- spar:state` block on this PR?",
8354 "maintainer",
8355 "spar-bot"
8356 ),
8357 "a question about the block was treated as the block"
8358 );
8359 assert!(
8360 !is_state_comment(&block, "maintainer", "spar-bot"),
8361 "somebody else's comment is not spar's to rewrite"
8362 );
8363 assert!(
8364 !is_state_comment(
8365 &format!("here is what spar writes:\n\n{block}"),
8366 "spar-bot",
8367 "spar-bot"
8368 ),
8369 "the marker has to open the body, not appear in it"
8370 );
8371 assert!(
8372 !is_state_comment(
8373 &format!("{STATE_MARKER}\n{{not json\n-->"),
8374 "spar-bot",
8375 "spar-bot"
8376 ),
8377 "a block that does not parse is not state"
8378 );
8379 assert!(
8380 !is_state_comment(&block, "spar-bot", ""),
8381 "an unknown viewer proves nothing about who wrote it"
8382 );
8383 }
8384
8385 #[test]
8386 fn a_malformed_state_comment_is_none_not_a_panic() {
8387 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
8388 }
8389
8390 #[test]
8391 fn atomic_write_leaves_no_temp_file() {
8392 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
8393 let _ = std::fs::remove_dir_all(&dir);
8394 let path = dir.join("state").join("pr-7.json");
8395 write_json_atomic(&path, &state()).unwrap();
8396 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
8397 .unwrap()
8398 .flatten()
8399 .filter_map(|e| e.file_name().to_str().map(str::to_string))
8400 .collect();
8401 assert_eq!(vec!["pr-7.json".to_string()], files);
8402 let _ = std::fs::remove_dir_all(&dir);
8403 }
8404
8405 #[test]
8406 fn atomic_write_overwrites_rather_than_accumulating() {
8407 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
8408 let _ = std::fs::remove_dir_all(&dir);
8409 let path = dir.join("pr-7.json");
8410 for round in 1..4 {
8411 let mut s = state();
8412 s.round = round;
8413 write_json_atomic(&path, &s).unwrap();
8414 }
8415 let back: PersistedState =
8416 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
8417 assert_eq!(3, back.round);
8418 let _ = std::fs::remove_dir_all(&dir);
8419 }
8420
8421 #[test]
8422 fn style_from_env_defaults_to_enforcing() {
8423 std::env::remove_var("SPAR_BAN_EM_DASH");
8424 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
8425 let style = style_from_env();
8426 assert!(style.ban_em_dash && style.ban_ai_attribution);
8427 assert!(
8428 !style.terse,
8429 "the commit filter must not truncate a commit message"
8430 );
8431 }
8432}
8433
8434#[cfg(test)]
8435mod comment_page_tests {
8436 use super::*;
8437
8438 #[test]
8439 fn a_single_merged_array_is_read() {
8440 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
8441 assert_eq!(2, pages.len());
8442 assert_eq!(Some(2), pages[1]["id"].as_i64());
8443 }
8444
8445 #[test]
8446 fn concatenated_pages_from_an_older_gh_are_read_too() {
8447 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
8448 assert_eq!(2, pages.len());
8449 }
8450
8451 #[test]
8455 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
8456 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
8457 let pages = parse_comment_pages(text);
8458 assert_eq!(2, pages.len(), "{pages:?}");
8459 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
8460 }
8461
8462 #[test]
8463 fn empty_output_is_no_comments_not_a_panic() {
8464 assert!(parse_comment_pages("").is_empty());
8465 assert!(parse_comment_pages(" ").is_empty());
8466 assert!(parse_comment_pages("[]").is_empty());
8467 }
8468
8469 #[test]
8470 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
8471 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
8472 }
8473
8474 #[test]
8475 fn a_write_postcheck_rejects_truncated_comment_pages() {
8476 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
8477 assert!(
8478 error.to_string().contains("unexpected comment pages"),
8479 "{error}"
8480 );
8481 }
8482
8483 #[test]
8484 fn a_write_postcheck_rejects_empty_or_non_array_output() {
8485 assert!(try_parse_comment_pages("").is_err());
8486 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
8487 assert!(try_parse_comment_pages("[]").is_ok());
8488 }
8489
8490 #[test]
8491 fn state_is_found_in_the_last_matching_comment() {
8492 let payload = |round: u32| {
8493 format!(
8494 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8495 )
8496 };
8497 let text = serde_json::to_string(&serde_json::json!([
8498 {"id": 1, "body": payload(1)},
8499 {"id": 2, "body": "looks good to me"},
8500 {"id": 3, "body": payload(5)},
8501 ]))
8502 .unwrap();
8503 let pages = parse_comment_pages(&text);
8504 let last = pages
8505 .iter()
8506 .rev()
8507 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
8508 .unwrap();
8509 assert_eq!(5, last.round);
8510 }
8511}
8512
8513#[cfg(test)]
8514mod linked_pr_tests {
8515 use super::*;
8516
8517 const REAL_PAYLOAD: &str = r#"[
8522 {"number":14252,"title":"fix: reject leading-dash branch names",
8523 "url":"https://github.com/cli/cli/pull/14252",
8524 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
8525 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8526 "url":"https://github.com/cli/cli/issues/14238"}]},
8527 {"number":14217,"title":"another change",
8528 "url":"https://github.com/cli/cli/pull/14217",
8529 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
8530 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8531 "url":"https://github.com/cli/cli/issues/9761"}]},
8532 {"number":14200,"title":"unlinked work",
8533 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
8534 ]"#;
8535
8536 #[test]
8537 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
8538 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
8539 assert_eq!(14252, pr.number);
8540 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
8541 }
8542
8543 #[test]
8544 fn the_right_pr_is_picked_out_of_several() {
8545 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
8546 }
8547
8548 #[test]
8549 fn an_issue_nobody_is_working_on_finds_nothing() {
8550 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
8551 }
8552
8553 #[test]
8554 fn an_unlinked_pr_is_never_matched() {
8555 for issue in [14200, 0, 1] {
8557 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
8558 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
8559 }
8560 }
8561 }
8562
8563 #[test]
8564 fn empty_or_broken_output_is_none_rather_than_a_panic() {
8565 assert!(find_linked_pr("", 1).is_none());
8566 assert!(find_linked_pr("[]", 1).is_none());
8567 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
8568 assert!(find_linked_pr("[{\"number\":", 1).is_none());
8569 }
8570
8571 #[test]
8578 fn an_unreadable_list_is_not_an_empty_list() {
8579 assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
8580 assert!(linked_pr_rows("[{\"number\":").is_none());
8581 assert!(linked_pr_rows("").is_none());
8582
8583 let empty = linked_pr_rows("[]").expect("an empty list is an answer");
8584 assert!(empty.is_empty());
8585 assert!(pick_linked_pr(&empty, 14238).is_none());
8586
8587 let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
8588 assert_eq!(3, listed.len());
8589 assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
8590 }
8591
8592 #[test]
8594 fn pr_view_reads_the_cross_repository_flag() {
8595 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
8596 "baseRefName":"main","state":"OPEN",
8597 "closingIssuesReferences":[],"isCrossRepository":true}"#;
8598 let pr: PrView = serde_json::from_str(json).unwrap();
8599 assert!(pr.is_cross_repository);
8600 assert!(pr.is_open());
8601
8602 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
8603 assert!(
8604 !serde_json::from_str::<PrView>(&same_repo)
8605 .unwrap()
8606 .is_cross_repository
8607 );
8608 }
8609}
8610
8611#[cfg(test)]
8612mod min_number_tests {
8613 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
8619 let mut numbers: Vec<i64> = open.to_vec();
8620 numbers.sort_unstable();
8621 if min_number > 0 {
8622 numbers.retain(|n| *n >= min_number);
8623 }
8624 numbers.truncate(limit);
8625 numbers
8626 }
8627
8628 #[test]
8629 fn the_floor_is_applied_before_the_cap_not_after() {
8630 let open = [12, 13, 14, 480, 481, 482];
8631 assert_eq!(vec![480, 481], pick(&open, 2, 480));
8632 assert!(!pick(&open, 2, 480).is_empty());
8635 }
8636
8637 #[test]
8638 fn no_floor_keeps_the_old_behaviour() {
8639 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
8640 }
8641
8642 #[test]
8643 fn the_floor_is_inclusive() {
8644 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
8645 }
8646
8647 #[test]
8648 fn a_floor_above_everything_open_yields_nothing() {
8649 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
8650 }
8651}