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::{Followup, Issue, IssueRef, ItemKind, PersistedState, PrRef, PrRow, PrView};
20use crate::proc::{self, ExecOpts};
21use crate::style::{self, Style};
22use crate::textsim;
23use crate::{bail, logdim, logwarn, spar_err};
24
25pub const FETCH_CEILING: usize = 500;
29
30pub const STATE_MARKER: &str = "<!-- spar:state";
33
34pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
42
43const WORKTREE_DIR: &str = ".spar-worktrees";
44const STATE_DIR: &str = ".spar";
45
46const SPLIT_SLOTS: u32 = 20;
51
52#[derive(Debug, Clone)]
53pub struct SplitPushError {
54 message: String,
55 retain_worktree: bool,
56}
57
58impl SplitPushError {
59 pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
60 Self {
61 message: message.into(),
62 retain_worktree,
63 }
64 }
65
66 pub fn retain_worktree(&self) -> bool {
67 self.retain_worktree
68 }
69}
70
71impl std::fmt::Display for SplitPushError {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.write_str(&self.message)
74 }
75}
76
77impl std::error::Error for SplitPushError {}
78
79fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
84 match attempt {
85 1 => format!("split-{parent}-{index}"),
86 n => format!("split-{parent}-{index}-{n}"),
87 }
88}
89
90#[derive(Debug)]
91pub struct Repo {
92 root: PathBuf,
93 pub style: Style,
94 pub branch_prefix: String,
95 pub state_store: StateStore,
96 pub followups: Followups,
97 pub drafts: Drafts,
98 viewer: OnceLock<String>,
104 checkpoints: Mutex<BTreeMap<i64, u64>>,
109 writes: WriteStats,
110}
111
112#[derive(Debug, Default)]
113struct WriteStats {
114 attempted: AtomicUsize,
115 failed: AtomicUsize,
116}
117
118#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
119pub(crate) struct WriteSummary {
120 pub(crate) attempted: usize,
121 pub(crate) failed: usize,
122}
123
124impl WriteSummary {
125 pub(crate) fn succeeded(self) -> usize {
126 self.attempted.saturating_sub(self.failed)
127 }
128}
129
130#[derive(Debug, Clone)]
136pub(crate) struct WorktreeBaseline {
137 attributes: AttributeState,
138 ignored_untracked: IgnoredState,
139 git_state: GitState,
140}
141
142#[derive(Debug, Clone)]
148pub(crate) struct WorktreeCheckpoint {
149 path: PathBuf,
150 attributes: AttributeState,
151 git_state: GitState,
152 ignored_untracked: IgnoredState,
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
156pub(crate) struct AttributeState {
157 files: BTreeMap<PathBuf, [u8; 32]>,
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub(crate) struct IgnoredState {
166 files: BTreeMap<PathBuf, UntrackedFile>,
167 ignored: BTreeSet<PathBuf>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
177struct UntrackedFile {
178 kind: u8,
179 len: u64,
180 modified: Option<std::time::SystemTime>,
181 created: Option<std::time::SystemTime>,
182 readonly: bool,
183 symlink_target: Option<Vec<u8>>,
184 #[cfg(unix)]
185 device: u64,
186 #[cfg(unix)]
187 inode: u64,
188 #[cfg(unix)]
189 mode: u32,
190 #[cfg(unix)]
191 change_seconds: i64,
192 #[cfg(unix)]
193 change_nanoseconds: i64,
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Eq)]
197pub(crate) struct GitState {
198 repositories: BTreeMap<PathBuf, RepositoryState>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
202struct RepositoryState {
203 head: String,
204 unsafe_index_flags: Vec<u8>,
205 tracked: BTreeMap<PathBuf, TrackedEntry>,
206 gitlinks: BTreeMap<PathBuf, String>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
210struct TrackedEntry {
211 index_mode: String,
212 index_oid: String,
213 worktree: Option<WorktreeFile>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217struct WorktreeFile {
218 mode: String,
219 #[cfg(unix)]
220 permissions: u32,
221 raw_oid: String,
222 fingerprint: [u8; 32],
223 content: [u8; 32],
224}
225
226struct Gitlink {
227 path: PathBuf,
228 oid: String,
229}
230
231struct IndexEntry {
232 path: PathBuf,
233 mode: String,
234 oid: String,
235}
236
237impl IgnoredState {
238 fn is_ignored(&self, path: &Path) -> bool {
239 self.ignored.contains(path)
240 }
241
242 fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
243 let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
244 paths.extend(after.files.keys().cloned());
245 paths
246 .into_iter()
247 .filter(|path| self.files.get(path) != after.files.get(path))
248 .collect()
249 }
250
251 fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
252 self.files
253 .iter()
254 .filter(|(path, state)| after.files.get(*path) != Some(*state))
255 .map(|(path, _)| path.clone())
256 .collect()
257 }
258
259 fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
260 after
261 .files
262 .keys()
263 .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
264 .cloned()
265 .collect()
266 }
267}
268
269fn is_generated_artifact(path: &Path) -> bool {
275 const DIRECTORIES: &[&str] = &[
276 "target",
277 "dist",
278 "node_modules",
279 "__pycache__",
280 ".pytest_cache",
281 ".mypy_cache",
282 ".ruff_cache",
283 ".tox",
284 ".nox",
285 ".venv",
286 "venv",
287 ".gradle",
288 ".build",
289 "DerivedData",
290 ".next",
291 ".nuxt",
292 ".svelte-kit",
293 ".turbo",
294 "coverage",
295 ];
296 path.components().any(|component| {
297 let std::path::Component::Normal(name) = component else {
298 return false;
299 };
300 DIRECTORIES
301 .iter()
302 .any(|directory| name == OsStr::new(directory))
303 })
304}
305
306fn merge_pr_args<'a>(
307 number: &'a str,
308 expected_head: Option<&'a str>,
309 delete_branch: bool,
310) -> Vec<&'a str> {
311 let mut args = vec!["pr", "merge", number, "--squash"];
312 if delete_branch {
313 args.push("--delete-branch");
314 }
315 if let Some(expected_head) = expected_head {
316 args.extend(["--match-head-commit", expected_head]);
317 }
318 args
319}
320
321fn reconcile_pr_creation(
322 branch: &str,
323 created: Result<String>,
324 found: Result<Option<PrRef>>,
325) -> Result<PrRef> {
326 match (created, found) {
327 (_, Ok(Some(pr))) => Ok(pr),
328 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
329 "PR creation reported success but none was found for {branch}"
330 ))),
331 (Err(create), Ok(None)) => Err(spar_err!(
332 "could not open a PR for {branch}. {}",
333 create.last_line()
334 )),
335 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
336 "PR creation reported success for {branch}, but it could not be verified. {}",
337 check.last_line()
338 ))),
339 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
340 "could not open a PR for {branch}. {} The result could not be verified: {}",
341 create.last_line(),
342 check.last_line()
343 ))),
344 }
345}
346
347fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
348 #[derive(Deserialize)]
349 #[serde(rename_all = "camelCase")]
350 struct Row {
351 number: i64,
352 #[serde(default)]
353 url: String,
354 #[serde(default)]
355 title: String,
356 base_ref_name: String,
357 }
358
359 let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
360 spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
361 })?;
362 Ok(rows
363 .into_iter()
364 .find(|row| row.base_ref_name == base)
365 .map(|row| PrRef {
366 number: row.number,
367 url: row.url,
368 title: row.title,
369 }))
370}
371
372fn has_exact_comment(comments: &[Value], body: &str) -> bool {
373 comments.iter().any(|comment| {
374 comment
375 .get("body")
376 .and_then(Value::as_str)
377 .is_some_and(|seen| seen == body)
378 })
379}
380
381fn reconcile_comment_post(
382 number: i64,
383 body: &str,
384 post_error: crate::error::SparError,
385 comments: Result<Vec<Value>>,
386) -> Result<()> {
387 match comments {
388 Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
389 Ok(_) => Err(post_error),
390 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
391 "could not comment on #{number}. {} The result could not be verified: {}",
392 post_error.last_line(),
393 read_error.last_line()
394 ))),
395 }
396}
397
398fn reconcile_issue_edit(
399 number: i64,
400 wanted: &str,
401 edit_error: crate::error::SparError,
402 observed: Result<String>,
403) -> Result<()> {
404 match observed {
405 Ok(body) if body == wanted => Ok(()),
406 Ok(_) => Err(spar_err!(
407 "could not rewrite the body of #{number}. {}",
408 edit_error.last_line()
409 )),
410 Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
411 "could not rewrite the body of #{number}. {} The result could not be verified: {}",
412 edit_error.last_line(),
413 read_error.last_line()
414 ))),
415 }
416}
417
418fn issue_url_has_number(url: &str) -> bool {
419 url.trim()
420 .rsplit('/')
421 .next()
422 .and_then(|tail| tail.parse::<i64>().ok())
423 .is_some_and(|number| number > 0)
424}
425
426fn reconcile_issue_creation(
427 title: &str,
428 created: Result<String>,
429 found: Result<Option<ExistingIssue>>,
430) -> Result<String> {
431 match (created, found) {
432 (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
433 (_, Ok(Some(issue))) => Ok(issue.url),
434 (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
435 "issue creation reported success but no matching issue was found for {title:?}"
436 ))),
437 (Err(create), Ok(None)) => Err(spar_err!(
438 "could not file issue {title:?}. {}",
439 create.last_line()
440 )),
441 (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
442 "issue creation reported success for {title:?}, but it could not be verified. {}",
443 check.last_line()
444 ))),
445 (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
446 "could not file issue {title:?}. {} The result could not be verified: {}",
447 create.last_line(),
448 check.last_line()
449 ))),
450 }
451}
452
453fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
454 if output.trim().is_empty() {
455 return Ok(None);
456 }
457 for line in output.lines() {
458 let mut fields = line.split_whitespace();
459 let oid = fields.next().unwrap_or_default();
460 let name = fields.next().unwrap_or_default();
461 if name == remote_ref && !oid.is_empty() {
462 return Ok(Some(oid.to_string()));
463 }
464 }
465 Err(spar_err!(
466 "origin returned an unexpected ref listing for {remote_ref}"
467 ))
468}
469
470fn reconcile_failed_split_push(
471 branch: &str,
472 push_error: crate::error::SparError,
473 local: Result<String>,
474 remote: Result<String>,
475) -> std::result::Result<(), SplitPushError> {
476 let remote_ref = format!("refs/heads/{branch}");
477 match (local, remote) {
478 (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
479 Ok(Some(oid)) if oid == local.trim() => Ok(()),
480 Ok(_) => Err(SplitPushError::new(
481 format!(
482 "could not create origin/{branch}. {} The remote branch is absent or points \
483 somewhere else. Nothing was overwritten.",
484 push_error.last_line()
485 ),
486 false,
487 )),
488 Err(check) => Err(SplitPushError::new(
489 format!(
490 "could not confirm whether origin/{branch} was created. {} The remote result \
491 could not be verified: {}",
492 push_error.last_line(),
493 check.last_line()
494 ),
495 true,
496 )),
497 },
498 (local, remote) => {
499 let check = match (local, remote) {
500 (Err(local), Err(remote)) => format!(
501 "the local commit could not be read: {}; origin could not be read: {}",
502 local.last_line(),
503 remote.last_line()
504 ),
505 (Err(local), _) => {
506 format!("the local commit could not be read: {}", local.last_line())
507 }
508 (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
509 _ => unreachable!(),
510 };
511 Err(SplitPushError::new(
512 format!(
513 "could not confirm whether origin/{branch} was created. {} The result could \
514 not be verified because {check}",
515 push_error.last_line()
516 ),
517 true,
518 ))
519 }
520 }
521}
522
523impl Repo {
524 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
525 let root =
526 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
527 let inside = proc::run_str(
530 &["git", "rev-parse", "--is-inside-work-tree"],
531 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
532 )
533 .unwrap_or_default();
534 if inside.trim() != "true" {
535 bail!("not a git repository: {}", root.display());
536 }
537 let repo = Self {
538 root,
539 style: cfg.style.clone(),
540 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
541 state_store: cfg.loop_cfg.state_store,
542 followups: cfg.loop_cfg.followups,
543 drafts: cfg.loop_cfg.drafts,
544 viewer: OnceLock::new(),
545 checkpoints: Mutex::new(BTreeMap::new()),
546 writes: WriteStats::default(),
547 };
548 repo.self_exclude();
549 Ok(repo)
550 }
551
552 fn self_exclude(&self) {
560 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
561 let git_dir = git_dir.trim();
562 if git_dir.is_empty() {
563 return;
564 }
565 let path = Path::new(git_dir).join("info").join("exclude");
566 let existing = std::fs::read_to_string(&path).unwrap_or_default();
567
568 let wanted = [format!("/{WORKTREE_DIR}/"), format!("/{STATE_DIR}/")];
569 let missing: Vec<&String> = wanted
570 .iter()
571 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
572 .collect();
573 if missing.is_empty() {
574 return;
575 }
576
577 use std::io::Write;
578 if let Some(parent) = path.parent() {
579 let _ = std::fs::create_dir_all(parent);
580 }
581 let mut block = String::new();
582 if !existing.is_empty() && !existing.ends_with('\n') {
583 block.push('\n');
584 }
585 block.push_str("\n# added by spar: its worktrees and run state\n");
586 for line in missing {
587 block.push_str(line);
588 block.push('\n');
589 }
590 if let Ok(mut file) = std::fs::OpenOptions::new()
591 .create(true)
592 .append(true)
593 .open(&path)
594 {
595 let _ = file.write_all(block.as_bytes());
596 }
597 }
598
599 pub fn root(&self) -> &Path {
600 &self.root
601 }
602
603 pub(crate) fn write_summary(&self) -> WriteSummary {
604 WriteSummary {
605 attempted: self.writes.attempted.load(Ordering::Relaxed),
606 failed: self.writes.failed.load(Ordering::Relaxed),
607 }
608 }
609
610 pub(crate) fn record_write<T, E>(
611 &self,
612 result: std::result::Result<T, E>,
613 ) -> std::result::Result<T, E> {
614 self.record_write_outcome(result.is_err());
615 result
616 }
617
618 pub(crate) fn record_failed_write<T, E>(
619 &self,
620 result: std::result::Result<T, E>,
621 ) -> std::result::Result<T, E> {
622 if result.is_err() {
623 self.record_write_outcome(true);
624 }
625 result
626 }
627
628 fn record_write_outcome(&self, failed: bool) {
629 self.writes.attempted.fetch_add(1, Ordering::Relaxed);
630 if failed {
631 self.writes.failed.fetch_add(1, Ordering::Relaxed);
632 }
633 }
634
635 pub fn clean(&self, text: &str) -> Result<String> {
641 let out = style::scrub(text, &self.style);
642 let bad = style::violations(&out, &self.style);
643 if !bad.is_empty() {
644 bail!(
645 "style gate could not clean text ({}): {}",
646 bad.join(", "),
647 style::clip(&out, 300)
648 );
649 }
650 Ok(out)
651 }
652
653 pub fn clean_body(&self, text: &str) -> Result<String> {
655 self.clean(&style::body(text, &self.style))
656 }
657
658 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
660 self.clean(&style::issue_body(text, &self.style))
661 }
662
663 pub fn clean_title(&self, text: &str) -> Result<String> {
674 Ok(style::title(&self.clean(text)?, &self.style))
675 }
676
677 pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
678 let title = self.record_failed_write(self.clean_title(text))?;
679 if title.trim().is_empty() {
680 return self.record_failed_write(Err(spar_err!(
681 "nothing left of the title after cleaning it"
682 )));
683 }
684 Ok(title)
685 }
686
687 pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
688 if self.followups == Followups::Issues {
689 self.clean_nonempty_title_for_write(text)
690 } else {
691 self.clean_title(text)
692 }
693 }
694
695 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
698 ExecOpts::new()
699 .cwd(cwd.unwrap_or(&self.root))
700 .check(check)
701 .timeout_secs(600)
702 }
703
704 pub fn git(&self, args: &[&str]) -> Result<String> {
705 self.git_at(None, args)
706 }
707
708 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
709 let argv = git_without_maintenance_argv(args);
710 proc::run(&argv, &self.git_opts(cwd, true))
711 }
712
713 fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
719 let argv = git_without_automation_argv(args);
720 proc::run(
721 &argv,
722 &self.git_opts(Some(cwd), true).stop_descendants(true),
723 )
724 }
725
726 fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
727 let argv = git_without_automation_argv(args);
728 proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
729 .map(|output| output.ok())
730 }
731
732 pub fn git_try(&self, args: &[&str]) -> String {
734 self.git_try_at(None, args)
735 }
736
737 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
738 let argv = git_without_maintenance_argv(args);
739 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
740 }
741
742 pub fn default_branch(&self, configured: &str) -> String {
745 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
746 match refname.trim().rsplit('/').next() {
747 Some(name) if !name.is_empty() => name.to_string(),
748 _ => configured.to_string(),
749 }
750 }
751
752 pub fn branch_for_issue(&self, issue: i64) -> String {
760 format!("{}issue-{issue}", self.branch_prefix)
761 }
762
763 pub fn branch_for_pr(&self, number: i64) -> String {
764 format!("{}pr-{number}", self.branch_prefix)
765 }
766
767 pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
776 format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
777 }
778
779 fn ledger_path(&self) -> PathBuf {
780 self.root.join(STATE_DIR).join("branches.json")
781 }
782
783 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
784 std::fs::read_to_string(self.ledger_path())
785 .ok()
786 .and_then(|text| serde_json::from_str(&text).ok())
787 .unwrap_or_default()
788 }
789
790 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
791 let mut data = self.known_branches();
792 data.insert(
793 branch.to_string(),
794 BranchRecord {
795 kind: kind.to_string(),
796 number,
797 },
798 );
799 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
800 logdim!("could not record branch {branch}: {e}");
801 }
802 }
803
804 pub fn forget_branch(&self, branch: &str) {
805 let mut data = self.known_branches();
806 if data.remove(branch).is_none() {
807 return;
808 }
809 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
810 logdim!("could not update the branch record: {e}");
811 }
812 }
813
814 fn worktree_path(&self, name: &str) -> PathBuf {
817 self.root.join(WORKTREE_DIR).join(name)
818 }
819
820 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
822 let branch = self.branch_for_issue(issue);
823 let path = self.worktree_path(&format!("issue-{issue}"));
824
825 self.refuse_issue_branch_rebuild(issue, base)?;
826 self.refuse_dirty_worktree(&path, &format!("worktree for issue #{issue}"))?;
827
828 if !self.branch_deletion_is_safe(&branch)? {
829 bail!(
830 "the existing branch {branch} has a tip or reflog-only commit that no surviving \
831 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
832 before retrying."
833 );
834 }
835
836 if !self.remove_worktree_at(&path)? {
837 bail!(
838 "the existing worktree for issue #{issue} could not be removed safely. Its \
839 branch was kept."
840 );
841 }
842 if !self.delete_branch_if_safe(&branch)? {
843 bail!(
844 "the existing branch {branch} changed or remained checked out while its \
845 worktree was being rebuilt. It was kept."
846 );
847 }
848
849 if let Some(parent) = path.parent() {
850 std::fs::create_dir_all(parent)
851 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
852 }
853
854 let path_str = path.display().to_string();
855 let remote_start = format!("origin/{base}");
856 let created = self
857 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
858 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
859
860 created.map_err(|e| {
863 spar_err!(
864 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
865 and does `origin` exist?",
866 e.last_line()
867 )
868 })?;
869 self.record_branch(&branch, "issue", issue);
870 Ok((path, branch))
871 }
872
873 pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
880 let branch = self.branch_for_issue(issue);
881 let base_remote_ref = format!("refs/heads/{base}");
882 let base_tracking_ref = format!("refs/remotes/origin/{base}");
883 let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
884 self.git(&["fetch", "--no-tags", "origin", &base_refspec])
885 .map_err(|e| {
886 spar_err!(
887 "could not refresh origin/{base} before checking issue #{issue}: {}",
888 e.last_line()
889 )
890 })?;
891
892 if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
893 let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
894 if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
895 bail!(
896 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
897 pull request accounts for them. Rebuilding it would force push over that \
898 work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
899 it, or delete it with `git push origin --delete {branch}` if the remote \
900 branch is no longer needed."
901 );
902 }
903 }
904
905 let local_ref = format!("refs/heads/{branch}");
906 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
907 let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
908 let recorded_pr = self
909 .known_branches()
910 .get(&branch)
911 .is_some_and(|record| record.kind == "pr");
912 let preserved = ahead == 0
913 || if recorded_pr {
914 self.local_branch_is_preserved(&branch)?
915 } else {
916 self.pull_request_holds(&branch, &local_ref, base)
917 };
918 if !preserved {
919 let listed = self
920 .commit_lines(&self.root, &local_ref, base)
921 .iter()
922 .map(|line| format!(" {line}"))
923 .collect::<Vec<_>>()
924 .join("\n");
925 bail!(
926 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
927 no pull request preserves them. Rebuilding it would delete the only copy.\n\
928 {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
929 it, or delete it with `git branch -D {branch}` if it is stale."
930 );
931 }
932 }
933 Ok(())
934 }
935
936 fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
937 let live_ref = format!("refs/heads/{branch}");
938 let tracking_ref = format!("refs/remotes/origin/{branch}");
939 let listed = self
940 .git(&["ls-remote", "--heads", "origin", &live_ref])
941 .map_err(|e| {
942 spar_err!(
943 "could not verify whether origin/{branch} still exists: {}",
944 e.last_line()
945 )
946 })?;
947
948 if remote_head_oid(&listed, &live_ref)?.is_some() {
949 let refspec = format!("+{live_ref}:{tracking_ref}");
950 self.git(&["fetch", "--no-tags", "origin", &refspec])
951 .map_err(|e| {
952 spar_err!(
953 "origin/{branch} exists but its tracking ref could not be refreshed: {}",
954 e.last_line()
955 )
956 })?;
957 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
958 bail!("origin/{branch} was fetched but its tracking ref is missing");
959 }
960 return Ok(Some(tracking_ref));
961 }
962
963 if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
964 return Ok(None);
965 }
966 let expected = self
967 .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
968 .trim()
969 .to_string();
970 self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
971 .map_err(|e| {
972 spar_err!(
973 "could not discard stale origin/{branch} tracking ref safely: {}",
974 e.last_line()
975 )
976 })?;
977 if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
978 bail!(
979 "origin/{branch} changed while its stale tracking ref was being removed. It was \
980 kept."
981 );
982 }
983 Ok(None)
984 }
985
986 fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
995 self.prs_for_branch(branch)
996 .iter()
997 .any(|pr| self.pr_head_holds(pr.number, refname, base))
998 }
999
1000 fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1001 let head = format!("refs/spar/pr-head/{number}");
1002 let refspec = format!("+refs/pull/{number}/head:{head}");
1003 if self.git(&["fetch", "origin", &refspec]).is_err() {
1004 return false;
1005 }
1006 let held = self.commits_held_by(refname, base, &head);
1007 self.git_try(&["update-ref", "-d", &head]);
1008 held
1009 }
1010
1011 pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1012 let argv = vec![
1013 "git".to_string(),
1014 "merge-base".to_string(),
1015 "--is-ancestor".to_string(),
1016 older.to_string(),
1017 newer.to_string(),
1018 ];
1019 let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1020 match out.code {
1021 0 => Ok(true),
1022 1 => Ok(false),
1023 _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1024 }
1025 }
1026
1027 fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1028 let head = format!("refs/spar/pr-head/{number}");
1029 let refspec = format!("+refs/pull/{number}/head:{head}");
1030 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1031 spar_err!(
1032 "could not verify the immutable head of PR #{number}: {}",
1033 e.last_line()
1034 )
1035 })?;
1036 let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1037 self.git_try(&["update-ref", "-d", &head]);
1038 held
1039 }
1040
1041 fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1042 let text = self.gh(&[
1043 "pr",
1044 "list",
1045 "--head",
1046 branch,
1047 "--state",
1048 "all",
1049 "--json",
1050 "number,url,title",
1051 ])?;
1052 serde_json::from_str(text.trim())
1053 .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1054 }
1055
1056 fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1057 let branch_ref = format!("refs/heads/{branch}");
1058 if record.kind == "pr" {
1059 return self.pr_head_contains_checked(record.number, &branch_ref);
1060 }
1061 let prs = self.branch_prs_checked(branch)?;
1062 if prs.is_empty() {
1063 return Ok(false);
1064 }
1065 for pr in prs {
1066 if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1067 return Ok(true);
1068 }
1069 }
1070 Ok(false)
1071 }
1072
1073 fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1074 let local_ref = format!("refs/heads/{branch}");
1075 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1076 return Ok(true);
1077 }
1078 let oid = self
1079 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1080 .trim()
1081 .to_string();
1082 let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
1083 if !durable_tip {
1084 let remote_ref = format!("refs/heads/{branch}");
1085 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
1086 durable_tip = remote.lines().any(|line| {
1087 line.split_whitespace()
1088 .next()
1089 .is_some_and(|remote_oid| remote_oid == oid)
1090 });
1091 }
1092 if !durable_tip {
1093 if let Some(record) = self.known_branches().get(branch) {
1094 durable_tip = self.branch_is_preserved_checked(branch, record)?;
1095 }
1096 }
1097 if !durable_tip {
1098 return Ok(false);
1099 }
1100 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1101 }
1102
1103 fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
1107 let local_ref = format!("refs/heads/{branch}");
1108 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1109 return Ok(true);
1110 }
1111 let expected = self
1112 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1113 .trim()
1114 .to_string();
1115 if !self.branch_deletion_is_safe(branch)? {
1116 return Ok(false);
1117 }
1118 let checked_out = self
1119 .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
1120 .lines()
1121 .any(|line| line == format!("branch {local_ref}"));
1122 if checked_out {
1123 return Ok(false);
1124 }
1125 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1126 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1127 }
1128
1129 fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
1130 let local_ref = review_ref(number);
1131 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1132 return Ok(true);
1133 }
1134 let oid = self
1135 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1136 .trim()
1137 .to_string();
1138 if !self.pr_head_contains_checked(number, &local_ref)? {
1139 return Ok(false);
1140 }
1141 ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1142 }
1143
1144 fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
1145 let local_ref = review_ref(number);
1146 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1147 return Ok(true);
1148 }
1149 let expected = self
1150 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1151 .trim()
1152 .to_string();
1153 if !self.review_ref_deletion_is_safe(number)? {
1154 return Ok(false);
1155 }
1156 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1157 Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1158 }
1159
1160 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
1164 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
1165 self.git_try(&["rev-list", "--count", &range, "--not", other])
1166 .trim()
1167 == "0"
1168 }
1169
1170 pub fn worktree_remove(&self, issue: i64) -> bool {
1171 let path = self.worktree_path(&format!("issue-{issue}"));
1172 match self.remove_worktree_at(&path) {
1173 Ok(removed) => removed,
1174 Err(error) => {
1175 logdim!(
1176 "kept {} because removal did not reach a confirmed quiet point: {}",
1177 path.display(),
1178 error.last_line()
1179 );
1180 false
1181 }
1182 }
1183 }
1184
1185 fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
1190 let wanted = std::fs::canonicalize(path)
1191 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
1192 if wanted != path {
1197 return Ok(false);
1198 }
1199 let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
1200 let raw = PathBuf::from(value.trim());
1201 let joined = if raw.is_absolute() {
1202 raw
1203 } else {
1204 cwd.join(raw)
1205 };
1206 std::fs::canonicalize(&joined)
1207 .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
1208 };
1209 let expected =
1210 self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
1211 let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
1212 let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
1213 let expected = resolve(&self.root, &expected)?;
1214 let actual = resolve(path, &actual)?;
1215 let top = resolve(path, &top)?;
1216 Ok(expected == actual && top == wanted)
1217 }
1218
1219 fn remove_worktree_at_with_force(&self, path: &Path, force: bool) -> Result<bool> {
1225 let existed = path.exists();
1226 if path.exists() {
1227 match self.worktree_belongs_to_repo(path) {
1228 Ok(true) => {}
1229 Ok(false) => {
1230 logdim!(
1231 "kept {} because it is not a worktree owned by this repository",
1232 path.display()
1233 );
1234 return Ok(false);
1235 }
1236 Err(e) => {
1237 logdim!(
1238 "kept {} because its worktree ownership could not be verified: {}",
1239 path.display(),
1240 e.last_line()
1241 );
1242 return Ok(false);
1243 }
1244 }
1245 if !force {
1246 match self.has_recoverable_work(path) {
1247 Ok(true) => {
1248 logdim!(
1249 "kept {} because it contains recoverable files or repository state",
1250 path.display()
1251 );
1252 return Ok(false);
1253 }
1254 Err(e) => {
1255 logdim!(
1256 "kept {} because its recoverable state could not be checked: {}",
1257 path.display(),
1258 e.last_line()
1259 );
1260 return Ok(false);
1261 }
1262 Ok(false) => {}
1263 }
1264 }
1265 }
1266 let path_str = path.display().to_string();
1267 let command_ok = if force {
1268 self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
1269 } else {
1270 self.git_try_without_automation(&["worktree", "remove", &path_str])?
1271 };
1272 Ok((command_ok || !existed) && !path.exists())
1273 }
1274
1275 fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
1276 self.remove_worktree_at_with_force(path, false)
1277 }
1278
1279 fn remove_worktree_at_force(&self, path: &Path) -> bool {
1281 match self.remove_worktree_at_with_force(path, true) {
1282 Ok(removed) => removed,
1283 Err(error) => {
1284 logdim!(
1285 "kept {} because removal did not reach a confirmed quiet point: {}",
1286 path.display(),
1287 error.last_line()
1288 );
1289 false
1290 }
1291 }
1292 }
1293
1294 fn remove_worktree_at_checked(&self, path: &Path) -> Result<bool> {
1299 if path.exists() && !self.worktree_belongs_to_repo(path)? {
1300 bail!(
1301 "{} is not a worktree owned by this repository, so it was kept",
1302 path.display()
1303 );
1304 }
1305 if path.exists() && self.has_recoverable_work(path)? {
1306 bail!(
1307 "the verified worktree at {} contains recoverable files or repository state. It \
1308 was kept.",
1309 path.display()
1310 );
1311 }
1312 let path_str = path.display().to_string();
1313 self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
1314 .map_err(|e| {
1315 e.with_message(format!(
1316 "could not remove the verified worktree at {}: {}. It was kept.",
1317 path.display(),
1318 e.last_line()
1319 ))
1320 })?;
1321 Ok(!path.exists())
1322 }
1323
1324 fn refuse_dirty_worktree(&self, path: &Path, label: &str) -> Result<()> {
1325 if !path.is_dir() {
1326 return Ok(());
1327 }
1328 let has_files = std::fs::read_dir(path)
1329 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
1330 .next()
1331 .is_some();
1332 let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
1333 spar_err!(
1334 "could not verify whether the existing {label} at {} belongs to this repository, \
1335 so it was kept: {}",
1336 path.display(),
1337 e.last_line()
1338 )
1339 })?;
1340 if !owned {
1341 if has_files {
1342 bail!(
1343 "the existing {label} at {} is not a worktree owned by \
1344 this repository. Refusing to remove it.",
1345 path.display()
1346 );
1347 }
1348 return Ok(());
1349 }
1350 if !path.join(".git").exists() {
1351 if has_files {
1352 bail!(
1353 "the existing {label} at {} is not a readable Git worktree and is not empty. \
1354 Refusing to remove it.",
1355 path.display()
1356 );
1357 }
1358 return Ok(());
1359 }
1360 let dirty = self.has_recoverable_work(path).map_err(|e| {
1361 spar_err!(
1362 "could not verify whether the existing {label} at {} is clean, so it was kept: \
1363 {}",
1364 path.display(),
1365 e.last_line()
1366 )
1367 })?;
1368 if dirty {
1369 bail!(
1370 "the existing {label} contains uncommitted changes or ignored files at {}. \
1371 Rebuilding it would delete those files.\nCommit or recover them before running this \
1372 command again, or use `spar clean --all` if they are not needed.",
1373 path.display()
1374 );
1375 }
1376 Ok(())
1377 }
1378
1379 pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
1381 let head = pr.head_ref_name.clone();
1382 if head.trim().is_empty() {
1383 bail!("PR #{} has no head branch to check out", pr.number);
1384 }
1385 let path = self.worktree_path(&format!("pr-{}", pr.number));
1386 let local = self.branch_for_pr(pr.number);
1387
1388 self.git(&["fetch", "origin", &head]).map_err(|e| {
1389 spar_err!(
1390 "could not fetch the branch behind PR #{}: {}",
1391 pr.number,
1392 e.last_line()
1393 )
1394 })?;
1395 let start = format!("origin/{head}");
1396 let start_ref = format!("refs/remotes/origin/{head}");
1397 let local_ref = format!("refs/heads/{local}");
1398 if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1399 let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
1400 if unpushed > 0 {
1401 bail!(
1402 "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
1403 on {start}. Rebuilding it would delete their branch.\nInspect the worktree at \
1404 {} and push or recover those commits before running this command again.",
1405 pr.number,
1406 path.display()
1407 );
1408 }
1409 }
1410 self.refuse_dirty_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
1411 if !self.branch_deletion_is_safe(&local)? {
1412 bail!(
1413 "the existing branch {local} has a tip or reflog-only commit that no surviving \
1414 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1415 before retrying."
1416 );
1417 }
1418 if !self.remove_worktree_at(&path)? {
1419 bail!(
1420 "the existing worktree for PR #{} could not be removed safely. Its branch was \
1421 kept.",
1422 pr.number
1423 );
1424 }
1425 if !self.delete_branch_if_safe(&local)? {
1426 bail!(
1427 "the existing branch {local} changed or remained checked out while the PR \
1428 worktree was being rebuilt. It was kept."
1429 );
1430 }
1431
1432 let path_str = path.display().to_string();
1433 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
1434 self.record_branch(&local, "pr", pr.number);
1435 Ok((path, head))
1436 }
1437
1438 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
1448 let path = self.worktree_path(&format!("review-{number}"));
1449 let local_ref = review_ref(number);
1450 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
1451
1452 self.refuse_review_worktree_changes(number)?;
1453
1454 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1455 spar_err!(
1456 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
1457 every pull request, so this usually means the number is wrong or `origin` does \
1458 not point at the repository the PR is on.",
1459 e.last_line()
1460 )
1461 })?;
1462
1463 if let Some(parent) = path.parent() {
1464 std::fs::create_dir_all(parent)
1465 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1466 }
1467 if !self.remove_worktree_at(&path)? {
1468 bail!(
1469 "the existing review worktree for PR #{number} could not be removed safely. Its \
1470 reference was kept."
1471 );
1472 }
1473 let path_str = path.display().to_string();
1474 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
1475 Ok(path)
1476 }
1477
1478 fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
1479 let path = self.worktree_path(&format!("review-{number}"));
1480 if !path.is_dir() {
1481 return Ok(());
1482 }
1483 let local_ref = review_ref(number);
1484 if !self.worktree_belongs_to_repo(&path)? {
1485 return Ok(());
1486 }
1487 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1488 bail!(
1489 "the existing review worktree for PR #{number} has no recorded head at \
1490 {local_ref}. Refusing to rebuild {}.",
1491 path.display()
1492 );
1493 }
1494 let worktree_head = self.head_oid_checked(&path)?;
1495 let recorded_head = self
1496 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1497 .trim()
1498 .to_string();
1499 if worktree_head != recorded_head {
1500 bail!(
1501 "the existing review worktree for PR #{number} has a local commit that is not on \
1502 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
1503 {} before retrying.",
1504 path.display()
1505 );
1506 }
1507 self.refuse_dirty_worktree(&path, &format!("review worktree for PR #{number}"))?;
1508 Ok(())
1509 }
1510
1511 pub fn worktree_for_split(
1524 &self,
1525 parent: i64,
1526 index: usize,
1527 start: &str,
1528 ) -> Result<(PathBuf, String)> {
1529 let slot = self.free_split_slot(parent, index)?;
1530 let branch = format!("{}{slot}", self.branch_prefix);
1531 let path = self.worktree_path(&slot);
1532
1533 if let Some(dir) = path.parent() {
1534 std::fs::create_dir_all(dir)
1535 .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
1536 }
1537 self.refuse_dirty_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
1538 if !self.remove_worktree_at(&path)? {
1542 bail!(
1543 "the existing worktree for part {index} of PR #{parent} could not be removed \
1544 safely. No branch was created."
1545 );
1546 }
1547
1548 let path_str = path.display().to_string();
1549 self.git(&["worktree", "add", "-b", &branch, &path_str, start])
1550 .map_err(|e| {
1551 spar_err!(
1552 "could not create a worktree for part {index} of #{parent}. {}",
1553 e.last_line()
1554 )
1555 })?;
1556 self.record_branch(&branch, "split", parent);
1559 Ok((path, branch))
1560 }
1561
1562 fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
1569 for attempt in 1..=SPLIT_SLOTS {
1570 let slot = split_slot(parent, index, attempt);
1571 let branch = format!("{}{slot}", self.branch_prefix);
1572 self.git_try(&["fetch", "origin", &branch]);
1573 if !self.rev_exists(&self.root, &branch)
1574 && !self.rev_exists(&self.root, &format!("origin/{branch}"))
1575 {
1576 return Ok(slot);
1577 }
1578 }
1579 bail!(
1580 "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
1581 names are all taken. Inspect the existing branches and child pull requests. Finish \
1582 recording the earlier split, or remove every retained local worktree and branch, \
1583 child pull request, and remote split branch before starting over.",
1584 self.branch_for_split(parent, index)
1585 )
1586 }
1587
1588 pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
1594 let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
1595 Ok(!self
1596 .git(&["ls-remote", "--heads", "origin", &pattern])?
1597 .trim()
1598 .is_empty())
1599 }
1600
1601 pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
1608 match self.branch_deletion_is_safe(branch) {
1609 Ok(true) => {}
1610 Ok(false) => {
1611 logdim!(
1612 "kept {branch} and {} because no surviving ref preserves its tip",
1613 dir.display()
1614 );
1615 return;
1616 }
1617 Err(error) => {
1618 logdim!(
1619 "kept {branch} and {} because preservation could not be verified: {}",
1620 dir.display(),
1621 error.last_line()
1622 );
1623 return;
1624 }
1625 }
1626 match self.remove_worktree_at(dir) {
1627 Ok(true) => match self.delete_branch_if_safe(branch) {
1628 Ok(true) => self.forget_branch(branch),
1629 Ok(false) => {
1630 logdim!("kept {branch} because its tip or reflog changed before deletion")
1631 }
1632 Err(error) => logdim!(
1633 "kept {branch} because deletion safety could not be rechecked: {}",
1634 error.last_line()
1635 ),
1636 },
1637 Ok(false) => {}
1638 Err(error) => logdim!(
1639 "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
1640 dir.display(),
1641 error.last_line()
1642 ),
1643 }
1644 }
1645
1646 pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
1652 let record = self.known_branches().get(branch).cloned();
1653 if record.is_none_or(|record| record.kind != "split") {
1654 logdim!("kept {branch} because no split branch record proves ownership");
1655 return false;
1656 }
1657 let local_ref = format!("refs/heads/{branch}");
1658 let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
1659 Ok(value) => value.trim().to_string(),
1660 Err(error) => {
1661 logdim!(
1662 "kept {branch} because its tip could not be checked: {}",
1663 error.last_line()
1664 );
1665 return false;
1666 }
1667 };
1668 if expected != disposable_head {
1669 logdim!("kept {branch} because it moved beyond the disposable slice");
1670 return false;
1671 }
1672 match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
1673 Ok(true) => {}
1674 Ok(false) => {
1675 logdim!(
1676 "kept {branch} because its reflog contains work outside the disposable slice"
1677 );
1678 return false;
1679 }
1680 Err(error) => {
1681 logdim!(
1682 "kept {branch} because its reflog could not be checked: {}",
1683 error.last_line()
1684 );
1685 return false;
1686 }
1687 }
1688 match self.head_oid_checked(dir) {
1689 Ok(head) if head == disposable_head => {}
1690 Ok(_) => {
1691 logdim!(
1692 "kept {branch} and {} because the worktree moved beyond the disposable slice",
1693 dir.display()
1694 );
1695 return false;
1696 }
1697 Err(error) => {
1698 logdim!(
1699 "kept {branch} and {} because its head could not be checked: {}",
1700 dir.display(),
1701 error.last_line()
1702 );
1703 return false;
1704 }
1705 }
1706 match self.remove_worktree_at_checked(dir) {
1707 Ok(true) => {}
1708 Ok(false) => return false,
1709 Err(error) => {
1710 logdim!(
1711 "kept {branch} and {} because the disposable slice could not be verified: {}",
1712 dir.display(),
1713 error.last_line()
1714 );
1715 return false;
1716 }
1717 }
1718 if let Err(error) =
1719 self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
1720 {
1721 logdim!(
1722 "kept {branch} because its exact disposable tip could not be deleted: {}",
1723 error.last_line()
1724 );
1725 return false;
1726 }
1727 match self.exact_ref_exists_checked(&self.root, &local_ref) {
1728 Ok(false) => {
1729 self.forget_branch(branch);
1730 true
1731 }
1732 Ok(true) => {
1733 logdim!("kept {branch} because its ref still exists after deletion");
1734 false
1735 }
1736 Err(error) => {
1737 logdim!(
1738 "kept the branch record for {branch} because deletion could not be verified: {}",
1739 error.last_line()
1740 );
1741 false
1742 }
1743 }
1744 }
1745
1746 pub fn release_review_worktree(&self, number: i64) {
1747 let path = self.worktree_path(&format!("review-{number}"));
1748 match self.review_ref_deletion_is_safe(number) {
1749 Ok(true) => {}
1750 Ok(false) => {
1751 logdim!(
1752 "kept {} because no surviving ref preserves its review history",
1753 path.display()
1754 );
1755 return;
1756 }
1757 Err(error) => {
1758 logdim!(
1759 "kept {} because review history could not be verified: {}",
1760 path.display(),
1761 error.last_line()
1762 );
1763 return;
1764 }
1765 }
1766 match self.remove_worktree_at(&path) {
1767 Ok(true) => match self.delete_review_ref_if_safe(number) {
1768 Ok(true) => {}
1769 Ok(false) => logdim!(
1770 "kept {} because its review history changed before deletion",
1771 review_ref(number)
1772 ),
1773 Err(error) => logdim!(
1774 "kept {} because deletion safety could not be rechecked: {}",
1775 review_ref(number),
1776 error.last_line()
1777 ),
1778 },
1779 Ok(false) => {}
1780 Err(error) => logdim!(
1781 "kept {} because removal did not reach a confirmed quiet point: {}",
1782 path.display(),
1783 error.last_line()
1784 ),
1785 }
1786 }
1787
1788 pub(crate) fn release_review_worktree_checked(
1791 &self,
1792 number: i64,
1793 checkpoint: &WorktreeCheckpoint,
1794 ) -> Result<()> {
1795 let path = self.worktree_path(&format!("review-{number}"));
1796 self.require_unchanged_worktree(
1797 &path,
1798 checkpoint,
1799 &format!("review worktree for PR #{number}"),
1800 )?;
1801 if !self.review_ref_deletion_is_safe(number)? {
1802 bail!(
1803 "the review reference for PR #{number} has reflog-only recovery history. The \
1804 worktree and reference were kept."
1805 );
1806 }
1807 if !self.remove_worktree_at_checked(&path)? {
1808 bail!(
1809 "the verified review worktree at {} could not be removed, so its reference was \
1810 kept",
1811 path.display()
1812 );
1813 }
1814 if !self.delete_review_ref_if_safe(number)? {
1815 bail!(
1816 "the review reference for PR #{number} changed before deletion. The reference was \
1817 kept."
1818 );
1819 }
1820 Ok(())
1821 }
1822
1823 pub fn release_pr_worktree(&self, number: i64) -> bool {
1824 let path = self.worktree_path(&format!("pr-{number}"));
1825 let local = self.branch_for_pr(number);
1826 match self.branch_deletion_is_safe(&local) {
1827 Ok(true) => {}
1828 Ok(false) => {
1829 logdim!(
1830 "kept {local} and {} because no surviving ref preserves its tip",
1831 path.display()
1832 );
1833 return false;
1834 }
1835 Err(error) => {
1836 logdim!(
1837 "kept {local} and {} because preservation could not be verified: {}",
1838 path.display(),
1839 error.last_line()
1840 );
1841 return false;
1842 }
1843 }
1844 match self.remove_worktree_at(&path) {
1845 Ok(true) => match self.delete_branch_if_safe(&local) {
1846 Ok(true) => {
1847 self.forget_branch(&local);
1848 true
1849 }
1850 Ok(false) => {
1851 logdim!("kept {local} because its tip or reflog changed before deletion");
1852 false
1853 }
1854 Err(error) => {
1855 logdim!(
1856 "kept {local} because deletion safety could not be rechecked: {}",
1857 error.last_line()
1858 );
1859 false
1860 }
1861 },
1862 Ok(false) => false,
1863 Err(error) => {
1864 logdim!(
1865 "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
1866 path.display(),
1867 error.last_line()
1868 );
1869 false
1870 }
1871 }
1872 }
1873
1874 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
1885 let remote = format!("origin/{base}");
1886 if self.rev_exists(cwd, &remote) {
1887 return remote;
1888 }
1889 if self.rev_exists(cwd, base) {
1890 logdim!("origin/{base} does not resolve, comparing against local {base}");
1891 return base.to_string();
1892 }
1893 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
1894 remote
1895 }
1896
1897 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
1898 let spec = format!("{refname}^{{commit}}");
1899 !self
1900 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
1901 .trim()
1902 .is_empty()
1903 }
1904
1905 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
1906 let range = format!("{}..HEAD", self.base_ref(cwd, base));
1907 !self
1908 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
1909 .trim()
1910 .is_empty()
1911 }
1912
1913 fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
1914 let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
1915 Ok(found.lines().any(|line| line.trim() == refname))
1916 }
1917
1918 fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
1919 let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
1920 count.trim().parse::<usize>().map_err(|e| {
1921 spar_err!(
1922 "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
1923 count.trim()
1924 )
1925 })
1926 }
1927
1928 pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
1929 let remote = format!("refs/remotes/origin/{base}");
1930 if self.exact_ref_exists_checked(cwd, &remote)? {
1931 return Ok(remote);
1932 }
1933 let local = format!("refs/heads/{base}");
1934 if self.exact_ref_exists_checked(cwd, &local)? {
1935 return Ok(local);
1936 }
1937 bail!("neither origin/{base} nor local branch {base} resolves")
1938 }
1939
1940 pub(crate) fn commit_count_checked(
1941 &self,
1942 cwd: &Path,
1943 refname: &str,
1944 base: &str,
1945 ) -> Result<usize> {
1946 let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
1947 let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
1948 count.trim().parse::<usize>().map_err(|e| {
1949 spar_err!(
1950 "git returned an invalid commit count for {range}: {:?} ({e})",
1951 count.trim()
1952 )
1953 })
1954 }
1955
1956 pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
1957 Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
1958 }
1959
1960 pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
1961 let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
1962 let head = head.trim().to_string();
1963 if head.is_empty() {
1964 bail!("git returned an empty HEAD for {}", cwd.display());
1965 }
1966 Ok(head)
1967 }
1968
1969 pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
1974 let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
1975 self.local_branch_is_preserved(branch.trim())
1976 }
1977
1978 pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
1981 let known = self.known_branches();
1982 let Some(record) = known.get(branch) else {
1983 return Ok(false);
1984 };
1985 self.branch_is_preserved_checked(branch, record)
1986 }
1987
1988 pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
1990 has_uncommitted_work(cwd)
1991 }
1992
1993 fn has_recoverable_work(&self, cwd: &Path) -> Result<bool> {
1996 repository_has_recoverable_work(cwd, true)
1997 }
1998
1999 pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2001 let attributes = attribute_state(cwd)?;
2002 Ok(WorktreeBaseline {
2003 attributes,
2004 ignored_untracked: ignored_untracked_state(cwd)?,
2005 git_state: safe_git_state(cwd)?,
2006 })
2007 }
2008
2009 pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2012 let attributes = attribute_state(cwd)?;
2013 Ok(WorktreeCheckpoint {
2014 path: std::fs::canonicalize(cwd)
2015 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2016 attributes,
2017 git_state: safe_git_state(cwd)?,
2018 ignored_untracked: ignored_untracked_state(cwd)?,
2019 })
2020 }
2021
2022 pub(crate) fn require_unchanged_worktree(
2025 &self,
2026 cwd: &Path,
2027 checkpoint: &WorktreeCheckpoint,
2028 label: &str,
2029 ) -> Result<()> {
2030 let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2031 crate::error::SparError::uncertain_write(format!(
2032 "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2033 cwd.display()
2034 ))
2035 })?;
2036 if resolved != checkpoint.path {
2037 return Err(uncertain_worktree_change(
2038 cwd,
2039 format!(
2040 "the {label} moved from {} to {} during inspection. It was kept.",
2041 checkpoint.path.display(),
2042 resolved.display()
2043 ),
2044 ));
2045 }
2046 let attributes = attribute_state(cwd).map_err(|e| {
2047 uncertain_worktree_change(
2048 cwd,
2049 format!(
2050 "could not verify attribute files in the {label} at {}: {}. It was kept.",
2051 cwd.display(),
2052 e.last_line()
2053 ),
2054 )
2055 })?;
2056 if attributes != checkpoint.attributes {
2057 return Err(uncertain_worktree_change(
2058 cwd,
2059 format!(
2060 "attribute files in the {label} at {} changed during inspection. It was \
2061 kept for recovery.",
2062 cwd.display()
2063 ),
2064 ));
2065 }
2066 let git_state = git_state(cwd).map_err(|e| {
2067 uncertain_worktree_change(
2068 cwd,
2069 format!(
2070 "could not verify the Git state of the {label} at {}: {}. It was kept.",
2071 cwd.display(),
2072 e.last_line()
2073 ),
2074 )
2075 })?;
2076 let ignored = ignored_untracked_state(cwd).map_err(|e| {
2077 uncertain_worktree_change(
2078 cwd,
2079 format!(
2080 "could not verify untracked files in the {label} at {}: {}. It was kept.",
2081 cwd.display(),
2082 e.last_line()
2083 ),
2084 )
2085 })?;
2086 if git_state != checkpoint.git_state || ignored != checkpoint.ignored_untracked {
2087 return Err(uncertain_worktree_change(
2088 cwd,
2089 format!(
2090 "the {label} at {} changed during a read-only inspection. It was kept for \
2091 recovery.",
2092 cwd.display()
2093 ),
2094 ));
2095 }
2096 Ok(())
2097 }
2098
2099 pub(crate) fn refuse_new_ignored_files(
2105 &self,
2106 cwd: &Path,
2107 baseline: &WorktreeBaseline,
2108 ) -> Result<()> {
2109 self.check_new_ignored_files(cwd, baseline, false)
2110 }
2111
2112 fn allow_generated_ignored_files(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
2113 self.check_new_ignored_files(cwd, baseline, true)
2114 }
2115
2116 fn check_new_ignored_files(
2117 &self,
2118 cwd: &Path,
2119 baseline: &WorktreeBaseline,
2120 allow_generated: bool,
2121 ) -> Result<()> {
2122 self.refuse_changed_attributes(cwd, baseline)?;
2123 let after = ignored_untracked_state(cwd).map_err(|e| {
2124 uncertain_worktree_change(
2125 cwd,
2126 format!(
2127 "could not verify untracked files in {} after editing: {}. The worktree was \
2128 kept for recovery.",
2129 cwd.display(),
2130 e.last_line()
2131 ),
2132 )
2133 })?;
2134 let changed = baseline.ignored_untracked.changed_paths(&after);
2135 if changed.is_empty() {
2136 return Ok(());
2137 }
2138 let (generated, changed): (Vec<_>, Vec<_>) = changed.into_iter().partition(|path| {
2139 allow_generated && after.is_ignored(path) && is_generated_artifact(path)
2140 });
2141 if !generated.is_empty() {
2142 logwarn!(
2143 "the editing call left {} generated artifact(s) under a known build or cache \
2144 directory in {}. They are not part of the commit and will keep the worktree \
2145 available for inspection.",
2146 generated.len(),
2147 cwd.display()
2148 );
2149 }
2150 if changed.is_empty() {
2151 return Ok(());
2152 }
2153 let mut listed = changed
2154 .iter()
2155 .take(5)
2156 .map(|path| format!("{:?}", path.as_os_str()))
2157 .collect::<Vec<_>>()
2158 .join(", ");
2159 if changed.len() > 5 {
2160 listed.push_str(&format!(", and {} more", changed.len() - 5));
2161 }
2162 Err(uncertain_worktree_change(
2163 cwd,
2164 format!(
2165 "the editing call created or changed untracked or ignored file(s) in {} that \
2166 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2167 recovery.",
2168 cwd.display()
2169 ),
2170 ))
2171 }
2172
2173 pub(crate) fn refuse_changed_existing_untracked(
2178 &self,
2179 cwd: &Path,
2180 baseline: &WorktreeBaseline,
2181 ) -> Result<()> {
2182 self.check_changed_existing_untracked(cwd, baseline, false)
2183 }
2184
2185 fn allow_changed_generated_artifacts(
2186 &self,
2187 cwd: &Path,
2188 baseline: &WorktreeBaseline,
2189 ) -> Result<()> {
2190 self.check_changed_existing_untracked(cwd, baseline, true)
2191 }
2192
2193 fn check_changed_existing_untracked(
2194 &self,
2195 cwd: &Path,
2196 baseline: &WorktreeBaseline,
2197 allow_generated: bool,
2198 ) -> Result<()> {
2199 self.refuse_changed_attributes(cwd, baseline)?;
2200 let after = ignored_untracked_state(cwd).map_err(|e| {
2201 uncertain_worktree_change(
2202 cwd,
2203 format!(
2204 "could not verify existing untracked files in {} after editing: {}. The \
2205 worktree was kept for recovery.",
2206 cwd.display(),
2207 e.last_line()
2208 ),
2209 )
2210 })?;
2211 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2212 if changed.is_empty() {
2213 return Ok(());
2214 }
2215 let (generated, changed): (Vec<_>, Vec<_>) = changed.into_iter().partition(|path| {
2216 allow_generated
2217 && baseline.ignored_untracked.is_ignored(path)
2218 && after.is_ignored(path)
2219 && is_generated_artifact(path)
2220 });
2221 if !generated.is_empty() {
2222 logwarn!(
2223 "the editing call changed {} existing generated artifact(s) under a known build \
2224 or cache directory in {}. They are not part of the commit and will keep the \
2225 worktree available for inspection.",
2226 generated.len(),
2227 cwd.display()
2228 );
2229 }
2230 if changed.is_empty() {
2231 return Ok(());
2232 }
2233 let mut listed = changed
2234 .iter()
2235 .take(5)
2236 .map(|path| format!("{:?}", path.as_os_str()))
2237 .collect::<Vec<_>>()
2238 .join(", ");
2239 if changed.len() > 5 {
2240 listed.push_str(&format!(", and {} more", changed.len() - 5));
2241 }
2242 Err(uncertain_worktree_change(
2243 cwd,
2244 format!(
2245 "the editing call changed or deleted existing untracked file(s) in {}: \
2246 {listed}. The worktree was kept for recovery.",
2247 cwd.display()
2248 ),
2249 ))
2250 }
2251
2252 pub(crate) fn refuse_unrepresented_tracked_changes(
2259 &self,
2260 cwd: &Path,
2261 baseline: &WorktreeBaseline,
2262 ) -> Result<()> {
2263 self.refuse_changed_attributes(cwd, baseline)?;
2264 let after = safe_git_state(cwd).map_err(|e| {
2265 uncertain_worktree_change(
2266 cwd,
2267 format!(
2268 "could not verify tracked files in {} after editing: {}. The worktree was \
2269 kept for recovery.",
2270 cwd.display(),
2271 e.last_line()
2272 ),
2273 )
2274 })?;
2275 let mut changed = Vec::new();
2276 let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
2277 uncertain_worktree_change(
2278 cwd,
2279 format!(
2280 "could not record untracked files before verifying transformed content in {}: \
2281 {}. The worktree was kept for recovery.",
2282 cwd.display(),
2283 e.last_line()
2284 ),
2285 )
2286 })?;
2287 let mut filter_was_run = false;
2288 let mut filter_problem = None;
2289 let mut repositories: BTreeSet<PathBuf> =
2290 baseline.git_state.repositories.keys().cloned().collect();
2291 repositories.extend(after.repositories.keys().cloned());
2292 'repositories: for repository_path in repositories {
2293 let before_repository = baseline.git_state.repositories.get(&repository_path);
2294 let after_repository = after.repositories.get(&repository_path);
2295 if before_repository.is_none() || after_repository.is_none() {
2296 changed.push(repository_path.clone());
2297 continue;
2298 }
2299 if before_repository.map(|repository| &repository.gitlinks)
2300 != after_repository.map(|repository| &repository.gitlinks)
2301 {
2302 changed.push(repository_path.join("<gitlinks>"));
2303 }
2304 let mut paths = BTreeSet::new();
2305 if let Some(repository) = before_repository {
2306 paths.extend(repository.tracked.keys().cloned());
2307 }
2308 if let Some(repository) = after_repository {
2309 paths.extend(repository.tracked.keys().cloned());
2310 }
2311 for path in paths {
2312 let before = before_repository.and_then(|repository| repository.tracked.get(&path));
2313 let current = after_repository.and_then(|repository| repository.tracked.get(&path));
2314 let worktree_changed =
2315 before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
2316 let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
2317 != current.map(|entry| (&entry.index_mode, &entry.index_oid));
2318 if !worktree_changed {
2319 continue;
2320 }
2321 if !index_changed {
2322 changed.push(repository_path.join(&path));
2323 continue;
2324 }
2325 let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
2326 let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
2327 let Some(current_entry) = current else {
2328 continue;
2329 };
2330 let Some(current_worktree) = current_worktree else {
2331 continue;
2332 };
2333 let content_changed =
2334 before_worktree.map(|file| file.content) != Some(current_worktree.content);
2335 let mode_changed = before_worktree.map(|file| file.mode.as_str())
2336 != Some(current_worktree.mode.as_str());
2337 let repository = cwd.join(&repository_path);
2338 let represented_content = if content_changed {
2339 filter_was_run = true;
2340 let result =
2341 filtered_index_content(&repository, &path, ¤t_entry.index_oid);
2342 self.refuse_changed_attributes(cwd, baseline)?;
2343 match result {
2344 Ok(expected) => expected == current_worktree.content,
2345 Err(error) => {
2346 filter_problem = Some(format!(
2347 "could not verify transformed content for {:?}: {}",
2348 repository_path.join(&path),
2349 error.last_line()
2350 ));
2351 false
2352 }
2353 }
2354 } else {
2355 true
2356 };
2357 let represented_mode =
2358 !mode_changed || current_worktree.mode == current_entry.index_mode;
2359 if !represented_content || !represented_mode {
2360 changed.push(repository_path.join(&path));
2361 }
2362 if filter_problem.is_some() {
2363 break 'repositories;
2364 }
2365 }
2366 }
2367 if filter_was_run {
2368 self.refuse_changed_attributes(cwd, baseline)?;
2369 let verified = safe_git_state(cwd).map_err(|e| {
2370 uncertain_worktree_change(
2371 cwd,
2372 format!(
2373 "could not recheck tracked files after verifying transformed content in \
2374 {}: {}. The worktree was kept for recovery.",
2375 cwd.display(),
2376 e.last_line()
2377 ),
2378 )
2379 })?;
2380 let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
2381 uncertain_worktree_change(
2382 cwd,
2383 format!(
2384 "could not recheck untracked files after verifying transformed content \
2385 in {}: {}. The worktree was kept for recovery.",
2386 cwd.display(),
2387 e.last_line()
2388 ),
2389 )
2390 })?;
2391 if verified != after || verified_untracked != before_filter_untracked {
2392 return Err(uncertain_worktree_change(
2393 cwd,
2394 "a content filter changed the worktree while SPAR verified the managed \
2395 commit. The worktree was kept for recovery.",
2396 ));
2397 }
2398 self.refuse_changed_existing_untracked(cwd, baseline)?;
2399 }
2400 if let Some(problem) = filter_problem {
2401 return Err(uncertain_worktree_change(
2402 cwd,
2403 format!("{problem}. The worktree was kept for recovery."),
2404 ));
2405 }
2406 if changed.is_empty() {
2407 return Ok(());
2408 }
2409 let mut listed = changed
2410 .iter()
2411 .take(5)
2412 .map(|path| format!("{:?}", path.as_os_str()))
2413 .collect::<Vec<_>>()
2414 .join(", ");
2415 if changed.len() > 5 {
2416 listed.push_str(&format!(", and {} more", changed.len() - 5));
2417 }
2418 Err(uncertain_worktree_change(
2419 cwd,
2420 format!(
2421 "the editing call changed tracked working-file bytes, modes, repositories, or \
2422 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
2423 recovery."
2424 ),
2425 ))
2426 }
2427
2428 pub(crate) fn refuse_changed_attributes(
2429 &self,
2430 cwd: &Path,
2431 baseline: &WorktreeBaseline,
2432 ) -> Result<()> {
2433 let after = attribute_state(cwd).map_err(|e| {
2434 uncertain_worktree_change(
2435 cwd,
2436 format!(
2437 "could not verify attribute files in {} after editing: {}. The worktree was \
2438 kept for recovery.",
2439 cwd.display(),
2440 e.last_line()
2441 ),
2442 )
2443 })?;
2444 if after == baseline.attributes {
2445 return Ok(());
2446 }
2447 Err(uncertain_worktree_change(
2448 cwd,
2449 format!(
2450 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
2451 refused to run a Git operation that could select a new external filter.",
2452 cwd.display()
2453 ),
2454 ))
2455 }
2456
2457 pub(crate) fn commit_pending_changes(
2462 &self,
2463 cwd: &Path,
2464 baseline: &WorktreeBaseline,
2465 preferred_subject: &str,
2466 fallback_subject: &str,
2467 ) -> Result<bool> {
2468 self.refuse_changed_attributes(cwd, baseline)?;
2469 self.allow_changed_generated_artifacts(cwd, baseline)?;
2470 refuse_unsafe_index_flags(cwd)?;
2471 if !self.has_uncommitted_changes(cwd)? {
2472 self.allow_generated_ignored_files(cwd, baseline)?;
2473 return Ok(false);
2474 }
2475 self.stage_managed_changes(cwd, baseline).map_err(|e| {
2476 e.with_message(format!(
2477 "could not stage changes in {}: {}",
2478 cwd.display(),
2479 e.last_line()
2480 ))
2481 })?;
2482 self.allow_generated_ignored_files(cwd, baseline)?;
2485 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
2486 if !changed_gitlinks.is_empty() {
2487 let listed = changed_gitlinks
2488 .iter()
2489 .take(5)
2490 .map(|path| format!("{:?}", path.as_os_str()))
2491 .collect::<Vec<_>>()
2492 .join(", ");
2493 bail!(
2494 "the editing call added or changed a gitlink at {listed}. It was staged but not \
2495 committed because the referenced repository objects might exist only inside \
2496 this worktree. The worktree was kept for recovery."
2497 );
2498 }
2499 let mut subject = self.clean_title(preferred_subject)?;
2500 if subject.trim().is_empty() {
2501 subject = self.clean_title(fallback_subject)?;
2502 }
2503 self.commit_staged_changes(cwd, &subject).map_err(|e| {
2504 e.with_message(format!(
2505 "could not commit changes in {}: {}. The staged files were kept.",
2506 cwd.display(),
2507 e.last_line()
2508 ))
2509 })?;
2510 if has_tracked_or_staged_work(cwd)? {
2511 bail!(
2512 "the commit in {} left additional uncommitted files. They were kept for \
2513 recovery.",
2514 cwd.display()
2515 );
2516 }
2517 self.allow_changed_generated_artifacts(cwd, baseline)?;
2518 self.allow_generated_ignored_files(cwd, baseline)?;
2519 Ok(true)
2520 }
2521
2522 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
2523 let after = ignored_untracked_state(cwd)?;
2524 self.git_at_without_automation(cwd, &["add", "-u"])?;
2525 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
2526 if paths.is_empty() {
2527 return Ok(());
2528 }
2529 let mut input = Vec::new();
2530 for path in paths {
2531 input.extend(os_str_bytes(path.as_os_str())?);
2532 input.push(0);
2533 }
2534 let argv = git_without_automation_argv(&[
2535 "--literal-pathspecs",
2536 "add",
2537 "--pathspec-from-file=-",
2538 "--pathspec-file-nul",
2539 ]);
2540 proc::run_with_input_bytes(
2541 &argv,
2542 &self.git_opts(Some(cwd), true).stop_descendants(true),
2543 &input,
2544 )?;
2545 Ok(())
2546 }
2547
2548 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
2551 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
2552 .map(|_| ())
2553 }
2554
2555 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
2562 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2563 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
2564 .trim()
2565 .parse()
2566 .unwrap_or(0)
2567 }
2568
2569 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2573 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2574 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2575 .lines()
2576 .map(str::to_string)
2577 .collect()
2578 }
2579
2580 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
2595 let ancestor = self
2596 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
2597 .is_ok();
2598 if !ancestor {
2599 return None;
2600 }
2601 let range = format!("{earlier}..{later}");
2602 Some(
2603 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2604 .lines()
2605 .map(str::to_string)
2606 .collect(),
2607 )
2608 }
2609
2610 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2613 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2614 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
2615 .lines()
2616 .map(str::trim)
2617 .filter(|line| !line.is_empty())
2618 .map(str::to_string)
2619 .collect()
2620 }
2621
2622 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
2638 let range = format!("{}...HEAD", self.base_ref(cwd, base));
2639 self.git_try_at(
2640 Some(cwd),
2641 &["diff", "--name-only", "--no-renames", "-z", &range],
2642 )
2643 .split('\0')
2644 .filter(|path| !path.is_empty())
2645 .map(str::to_string)
2646 .collect()
2647 }
2648
2649 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
2652 let base_ref = self.base_ref(cwd, base);
2653 let out = self
2654 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
2655 .map_err(|e| {
2656 spar_err!(
2657 "could not find where {refname} and {base_ref} diverged. {}",
2658 e.last_line()
2659 )
2660 })?;
2661 let sha = out.trim().to_string();
2662 if sha.is_empty() {
2663 bail!("{refname} and {base_ref} share no history");
2664 }
2665 Ok(sha)
2666 }
2667
2668 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
2669 let range = format!("{}...HEAD", self.base_ref(cwd, base));
2670 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
2671 full.trim().to_string()
2672 }
2673
2674 pub fn rewrite_commits_if_needed(&self, cwd: &Path, base: &str) -> Result<()> {
2679 let range = format!("{}..HEAD", self.base_ref(cwd, base));
2680 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
2681
2682 let offenders = raw
2683 .split('\x1e')
2684 .filter_map(|entry| entry.split_once('\0'))
2685 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
2686 .count();
2687 if offenders == 0 {
2688 return Ok(());
2689 }
2690 logdim!("{offenders} commit message(s) violated style rules, rewriting");
2691
2692 let exe = self_binary()?;
2693 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
2694
2695 let argv: Vec<String> = [
2696 "git",
2697 "filter-branch",
2698 "-f",
2699 "--msg-filter",
2700 &filter,
2701 &range,
2702 ]
2703 .iter()
2704 .map(|s| s.to_string())
2705 .collect();
2706 let opts = ExecOpts::new()
2707 .cwd(cwd)
2708 .check(false)
2709 .timeout_secs(600)
2710 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
2711 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
2712 .env(
2713 "SPAR_BAN_AI_ATTRIBUTION",
2714 bool_env(self.style.ban_ai_attribution),
2715 );
2716 let _ = proc::run(&argv, &opts);
2717
2718 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
2719 if !style::violations(&after, &self.style).is_empty() {
2720 bail!(
2721 "commit messages still violate style rules after a rewrite in {}.",
2722 cwd.display()
2723 );
2724 }
2725 Ok(())
2726 }
2727
2728 pub fn push(&self, cwd: &Path, branch: &str) -> Result<()> {
2734 let refspec = format!("HEAD:{branch}");
2735 let pushed = self
2736 .git_at(
2737 Some(cwd),
2738 &["push", "--force-with-lease", "origin", &refspec],
2739 )
2740 .map(|_| ())
2741 .map_err(|e| {
2742 spar_err!(
2743 "could not push to origin/{branch}. {}\nCheck push access and whether the \
2744 branch moved under you.",
2745 e.last_line()
2746 )
2747 });
2748 self.record_write(pushed)
2749 }
2750
2751 pub fn push_split_branch(
2760 &self,
2761 cwd: &Path,
2762 branch: &str,
2763 ) -> std::result::Result<(), SplitPushError> {
2764 let remote_ref = format!("refs/heads/{branch}");
2765 let lease = format!("--force-with-lease={remote_ref}:");
2766 let refspec = format!("HEAD:{remote_ref}");
2767 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
2768 Ok(_) => Ok(()),
2769 Err(push_error) => {
2770 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
2771 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
2772 reconcile_failed_split_push(branch, push_error, local, remote)
2773 }
2774 };
2775 self.record_write(result)
2776 }
2777
2778 pub fn gh(&self, args: &[&str]) -> Result<String> {
2781 self.gh_at(None, args)
2782 }
2783
2784 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
2785 let mut argv = vec!["gh".to_string()];
2786 argv.extend(args.iter().map(|s| s.to_string()));
2787 proc::run(
2788 &argv,
2789 &ExecOpts::new()
2790 .cwd(cwd.unwrap_or(&self.root))
2791 .timeout_secs(300),
2792 )
2793 }
2794
2795 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
2801 let mut argv = vec!["gh".to_string()];
2802 argv.extend(args.iter().map(|s| s.to_string()));
2803 proc::run(
2804 &argv,
2805 &ExecOpts::new()
2806 .cwd(&self.root)
2807 .timeout_secs(300)
2808 .stdin(stdin),
2809 )
2810 }
2811
2812 pub fn gh_try(&self, args: &[&str]) -> String {
2813 let mut argv = vec!["gh".to_string()];
2814 argv.extend(args.iter().map(|s| s.to_string()));
2815 proc::run(
2816 &argv,
2817 &ExecOpts::new()
2818 .cwd(&self.root)
2819 .check(false)
2820 .timeout_secs(300),
2821 )
2822 .unwrap_or_default()
2823 }
2824
2825 pub fn viewer_login(&self) -> Result<&str> {
2836 if let Some(login) = self.viewer.get() {
2837 return Ok(login);
2838 }
2839 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
2840 let login = if !rest.trim().is_empty() {
2841 rest.trim().to_string()
2842 } else {
2843 self.gh(&[
2846 "api",
2847 "graphql",
2848 "-f",
2849 "query={ viewer { login } }",
2850 "--jq",
2851 ".data.viewer.login",
2852 ])
2853 .map_err(|e| {
2854 spar_err!(
2855 "could not find out who `gh` is authenticated as, so spar cannot tell its \
2856 own comments from anybody else's. {}\nRun `gh auth status`.",
2857 e.last_line()
2858 )
2859 })?
2860 .trim()
2861 .to_string()
2862 };
2863 if login.is_empty() {
2864 bail!("`gh` reported an empty login. Run `gh auth status`.");
2865 }
2866 Ok(self.viewer.get_or_init(|| login))
2867 }
2868
2869 pub fn read_issue(&self, number: i64) -> Result<Issue> {
2875 let text = self
2876 .gh(&[
2877 "issue",
2878 "view",
2879 &number.to_string(),
2880 "--json",
2881 "number,title,body,labels,state,url",
2882 ])
2883 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
2884 serde_json::from_str(&text)
2885 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
2886 }
2887
2888 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
2889 let mut issues = Vec::new();
2890 for number in numbers {
2891 let text = self
2892 .gh(&[
2893 "issue",
2894 "view",
2895 &number.to_string(),
2896 "--json",
2897 "number,title,body,labels,state,url",
2898 ])
2899 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
2900 let issue: Issue = serde_json::from_str(&text)
2901 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
2902 if issue.is_closed() {
2903 crate::log!("issue #{number} is closed, skipping");
2904 continue;
2905 }
2906 issues.push(issue);
2907 }
2908 if issues.is_empty() {
2909 bail!("no open issues to work on");
2910 }
2911 Ok(issues)
2912 }
2913
2914 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
2920 #[derive(Deserialize)]
2921 struct Row {
2922 number: i64,
2923 }
2924 let text = self.gh(&[
2925 kind,
2926 "list",
2927 "--state",
2928 "open",
2929 "--limit",
2930 &FETCH_CEILING.to_string(),
2931 "--json",
2932 "number",
2933 ])?;
2934 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
2935 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
2936 numbers.sort_unstable();
2937
2938 let noun = if kind == "issue" { "issues" } else { "PRs" };
2939 let found = numbers.len();
2940 if min_number > 0 {
2941 numbers.retain(|n| *n >= min_number);
2942 let skipped = found - numbers.len();
2943 if skipped > 0 {
2944 crate::log!("{skipped} open {noun} below #{min_number} skipped");
2945 }
2946 }
2947 if found >= FETCH_CEILING {
2948 crate::log!(
2949 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
2950 considered."
2951 );
2952 }
2953 if numbers.len() > limit {
2954 crate::log!(
2955 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
2956 explicitly.",
2957 numbers.len()
2958 );
2959 numbers.truncate(limit);
2960 }
2961 Ok(numbers)
2962 }
2963
2964 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
2966 self.open_numbers("issue", limit, min_number)
2967 }
2968
2969 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
2970 self.open_numbers("pr", limit, min_number)
2971 }
2972
2973 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
2974 self.branch_prs(branch, "open").into_iter().next()
2975 }
2976
2977 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
2980 let text = self.gh(&[
2981 "pr",
2982 "list",
2983 "--head",
2984 branch,
2985 "--base",
2986 base,
2987 "--state",
2988 "open",
2989 "--json",
2990 "number,url,title,baseRefName",
2991 ])?;
2992 pr_for_base(&text, branch, base)
2993 }
2994
2995 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
2999 self.branch_prs(branch, "all")
3000 }
3001
3002 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3003 let text = self.gh_try(&[
3004 "pr",
3005 "list",
3006 "--head",
3007 branch,
3008 "--state",
3009 state,
3010 "--json",
3011 "number,url,title",
3012 ]);
3013 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3014 }
3015
3016 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3022 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3023 let text = self
3024 .gh(&[
3025 "api",
3026 &path,
3027 "--jq",
3028 "if .pull_request then \"pr\" else \"issue\" end",
3029 ])
3030 .map_err(|e| {
3031 spar_err!(
3032 "no issue or pull request #{number} in this repository. {}",
3033 e.last_line()
3034 )
3035 })?;
3036 match text.trim() {
3037 "pr" => Ok(ItemKind::Pr),
3038 "issue" => Ok(ItemKind::Issue),
3039 other => Err(spar_err!(
3040 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3041 )),
3042 }
3043 }
3044
3045 pub fn open_pr_for_issue(&self, issue: i64) -> Option<PrRef> {
3051 if let Some(pr) = self.pr_for_branch(&self.branch_for_issue(issue)) {
3052 return Some(pr);
3053 }
3054 let text = self.gh_try(&[
3055 "pr",
3056 "list",
3057 "--state",
3058 "open",
3059 "--limit",
3060 &FETCH_CEILING.to_string(),
3061 "--json",
3062 "number,url,title,closingIssuesReferences",
3063 ]);
3064 find_linked_pr(&text, issue)
3065 }
3066
3067 pub fn pr_view(&self, number: i64) -> Result<PrView> {
3068 let text = self.gh(&[
3069 "pr",
3070 "view",
3071 &number.to_string(),
3072 "--json",
3073 "number,url,title,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3074 ])?;
3075 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3076 }
3077
3078 fn try_pr_state(&self, number: i64) -> Result<String> {
3079 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3080 serde_json::from_str::<Value>(text.trim())
3081 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3082 .get("state")
3083 .and_then(Value::as_str)
3084 .map(str::to_string)
3085 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3086 }
3087
3088 pub fn pr_state(&self, number: i64) -> String {
3089 self.try_pr_state(number).unwrap_or_default()
3090 }
3091
3092 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3094 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3095 let oid = serde_json::from_str::<Value>(&text)
3096 .ok()
3097 .and_then(|value| {
3098 value
3099 .get("headRefOid")
3100 .and_then(Value::as_str)
3101 .map(str::trim)
3102 .filter(|oid| !oid.is_empty())
3103 .map(str::to_string)
3104 })
3105 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3106 Ok(oid)
3107 }
3108
3109 pub fn create_pr(
3110 &self,
3111 cwd: &Path,
3112 branch: &str,
3113 base: &str,
3114 title: &str,
3115 body: &str,
3116 ) -> Result<PrRef> {
3117 let title = self.record_failed_write(self.clean_title(title))?;
3118 let body = self.record_failed_write(self.clean(body))?;
3119 let mut argv = vec![
3120 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3121 ];
3122 if self.drafts != Drafts::Never {
3123 argv.push("--draft");
3124 }
3125 let created = self.gh_at(Some(cwd), &argv);
3126 let found = self.try_pr_for_branch(branch, base);
3127 self.record_write(reconcile_pr_creation(branch, created, found))
3128 }
3129
3130 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3131 let body = self.record_failed_write(self.clean(body))?;
3132 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3133 if has_exact_comment(&comments, &body) {
3134 return Ok(());
3135 }
3136 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3137 let result = match posted {
3138 Ok(_) => Ok(()),
3139 Err(post_error) => {
3140 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3141 }
3142 };
3143 self.record_write(result)
3144 }
3145
3146 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3147 let body = self.record_failed_write(self.clean(body))?;
3148 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3149 if has_exact_comment(&comments, &body) {
3150 return Ok(());
3151 }
3152 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3153 let result = match posted {
3154 Ok(_) => Ok(()),
3155 Err(post_error) => {
3156 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3157 }
3158 };
3159 self.record_write(result)
3160 }
3161
3162 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3167 self.comment_issue(number, body)?;
3168 let n = number.to_string();
3169 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3170 Ok(_) => Ok(()),
3171 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3173 spar_err!(
3174 "commented on #{number} but could not close it: {}",
3175 e.last_line()
3176 )
3177 }),
3178 };
3179 self.record_write(closed)
3180 }
3181
3182 pub fn edit_issue_body(
3197 &self,
3198 number: i64,
3199 expected: &str,
3200 body: &str,
3201 inserted: &str,
3202 ) -> Result<()> {
3203 let cleaned = self.record_failed_write(self.clean(inserted))?;
3204 if cleaned.trim() != inserted.trim() {
3205 return self.record_failed_write(Err(spar_err!(
3206 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
3207 )));
3208 }
3209 let current = self.record_failed_write(self.issue_body(number))?;
3210 if current != expected {
3211 return self.record_failed_write(Err(spar_err!(
3212 "the body of #{number} changed since it was read, so it was left alone rather \
3213 than written over."
3214 )));
3215 }
3216 let edited = self.gh_stdin(
3217 &["issue", "edit", &number.to_string(), "--body-file", "-"],
3218 body,
3219 );
3220 let result = match edited {
3221 Ok(_) => Ok(()),
3222 Err(edit_error) => {
3223 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
3224 }
3225 };
3226 self.record_write(result)
3227 }
3228
3229 pub fn issue_body(&self, number: i64) -> Result<String> {
3231 #[derive(Deserialize)]
3232 struct Row {
3233 #[serde(default)]
3234 body: Option<String>,
3235 }
3236 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
3237 let row: Row = serde_json::from_str(text.trim())
3238 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3239 Ok(row.body.unwrap_or_default())
3240 }
3241
3242 pub fn open_issue_rows(&self) -> Vec<Issue> {
3247 let text = self.gh_try(&[
3248 "issue",
3249 "list",
3250 "--state",
3251 "open",
3252 "--limit",
3253 &FETCH_CEILING.to_string(),
3254 "--json",
3255 "number,title,body,labels,state,url",
3256 ]);
3257 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
3258 }
3259
3260 pub fn open_pr_rows(&self) -> Vec<PrRow> {
3262 let text = self.gh_try(&[
3263 "pr",
3264 "list",
3265 "--state",
3266 "open",
3267 "--limit",
3268 &FETCH_CEILING.to_string(),
3269 "--json",
3270 "number,title,changedFiles,additions,deletions",
3271 ]);
3272 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
3273 }
3274
3275 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
3276 self.create_issue_apart_from(title, body, None)
3277 }
3278
3279 pub fn create_issue_apart_from(
3280 &self,
3281 title: &str,
3282 body: &str,
3283 apart_from: Option<i64>,
3284 ) -> Result<String> {
3285 let title = self.record_failed_write(self.clean_title(title))?;
3286 let body = self.record_failed_write(self.clean_issue_body(body))?;
3287 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
3288 let result = match created {
3289 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
3290 created => {
3291 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
3292 reconcile_issue_creation(&title, created, found)
3293 }
3294 };
3295 self.record_write(result)
3296 }
3297}
3298
3299#[derive(Debug, Clone)]
3301pub struct ExistingIssue {
3302 pub number: i64,
3303 pub url: String,
3304 pub title: String,
3305 pub body: String,
3306 pub open: bool,
3307}
3308
3309impl Repo {
3310 pub(crate) fn try_exact_issue_apart_from(
3311 &self,
3312 title: &str,
3313 body: &str,
3314 apart_from: Option<i64>,
3315 ) -> Result<Option<ExistingIssue>> {
3316 #[derive(Deserialize)]
3317 #[serde(rename_all = "camelCase")]
3318 struct Row {
3319 number: i64,
3320 #[serde(default)]
3321 title: String,
3322 #[serde(default)]
3323 url: String,
3324 #[serde(default)]
3325 body: Option<String>,
3326 #[serde(default)]
3327 state: String,
3328 }
3329
3330 let text = self.gh(&[
3331 "issue",
3332 "list",
3333 "--state",
3334 "all",
3335 "--limit",
3336 "100",
3337 "--json",
3338 "number,title,url,body,state",
3339 ])?;
3340 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3341 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
3342 Ok(rows
3343 .into_iter()
3344 .filter(|row| Some(row.number) != apart_from)
3345 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
3346 .map(|row| ExistingIssue {
3347 number: row.number,
3348 url: row.url,
3349 title: row.title,
3350 body: row.body.unwrap_or_default(),
3351 open: row.state.eq_ignore_ascii_case("open"),
3352 }))
3353 }
3354
3355 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
3363 self.find_similar_issue_apart_from(title, body, None)
3364 }
3365
3366 pub fn find_similar_issue_apart_from(
3372 &self,
3373 title: &str,
3374 body: &str,
3375 apart_from: Option<i64>,
3376 ) -> Option<ExistingIssue> {
3377 self.try_find_similar_issue_apart_from(title, body, apart_from)
3378 .ok()
3379 .flatten()
3380 }
3381
3382 pub fn try_find_similar_issue_apart_from(
3384 &self,
3385 title: &str,
3386 body: &str,
3387 apart_from: Option<i64>,
3388 ) -> Result<Option<ExistingIssue>> {
3389 #[derive(Deserialize)]
3390 #[serde(rename_all = "camelCase")]
3391 struct Row {
3392 number: i64,
3393 #[serde(default)]
3394 title: String,
3395 #[serde(default)]
3396 url: String,
3397 #[serde(default)]
3398 body: String,
3399 #[serde(default)]
3400 state: String,
3401 }
3402 if title.trim().is_empty() {
3403 return Ok(None);
3404 }
3405 let query: String = title
3408 .chars()
3409 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3410 .take(120)
3411 .collect();
3412 let text = self.gh(&[
3413 "issue",
3414 "list",
3415 "--state",
3416 "all",
3417 "--limit",
3418 "100",
3419 "--search",
3420 query.trim(),
3421 "--json",
3422 "number,title,url,body,state",
3423 ])?;
3424 let rows: Vec<Row> = serde_json::from_str(text.trim())
3425 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
3426 let wanted = format!("{title} {body}");
3427
3428 Ok(rows
3429 .into_iter()
3430 .filter(|row| Some(row.number) != apart_from)
3431 .find(|row| {
3432 let theirs = format!("{} {}", row.title, row.body);
3433 row.title.trim().eq_ignore_ascii_case(title.trim())
3434 || textsim::same_subject(&wanted, &theirs)
3435 })
3436 .map(|row| ExistingIssue {
3437 number: row.number,
3438 url: row.url,
3439 title: row.title,
3440 open: row.state.eq_ignore_ascii_case("open"),
3441 body: row.body,
3442 }))
3443 }
3444
3445 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
3447 #[derive(Deserialize)]
3448 struct Row {
3449 title: String,
3450 url: String,
3451 }
3452 let needle = title.trim().to_lowercase();
3453 if needle.is_empty() {
3454 return None;
3455 }
3456 let query: String = title
3458 .chars()
3459 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3460 .take(120)
3461 .collect();
3462 let text = self.gh_try(&[
3463 "issue",
3464 "list",
3465 "--state",
3466 "all",
3467 "--limit",
3468 "100",
3469 "--search",
3470 query.trim(),
3471 "--json",
3472 "number,title,url",
3473 ]);
3474 serde_json::from_str::<Vec<Row>>(text.trim())
3475 .ok()?
3476 .into_iter()
3477 .find(|row| row.title.trim().to_lowercase() == needle)
3478 .map(|row| row.url)
3479 }
3480
3481 pub fn mark_ready(&self, number: i64) -> bool {
3489 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
3490 Ok(_) => true,
3491 Err(e) => {
3492 logdim!(
3493 "PR #{number} is approved but could not be taken out of draft: {}",
3494 e.last_line()
3495 );
3496 false
3497 }
3498 }
3499 }
3500
3501 pub fn merge_pr(&self, number: i64) -> Result<()> {
3505 let n = number.to_string();
3506 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
3507 Ok(_) => Ok(()),
3508 Err(e) => {
3509 if self.pr_state(number) == "MERGED" {
3510 logdim!(
3511 "PR #{number} merged; branch cleanup did not finish: {}",
3512 e.last_line()
3513 );
3514 Ok(())
3515 } else {
3516 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3517 }
3518 }
3519 };
3520 self.record_write(merged)
3521 }
3522
3523 pub fn merge_pr_at_head(
3525 &self,
3526 number: i64,
3527 expected_head: &str,
3528 delete_branch: bool,
3529 ) -> Result<()> {
3530 let n = number.to_string();
3531 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
3532 Ok(_) => Ok(()),
3533 Err(e) => {
3534 if self.pr_state(number) == "MERGED" {
3535 logdim!(
3536 "PR #{number} merged; branch cleanup did not finish: {}",
3537 e.last_line()
3538 );
3539 Ok(())
3540 } else {
3541 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3542 }
3543 }
3544 };
3545 self.record_write(merged)
3546 }
3547
3548 pub fn followups_path(&self) -> PathBuf {
3553 self.root.join(STATE_DIR).join("followups.md")
3554 }
3555
3556 pub fn worked_followups_path(&self) -> PathBuf {
3564 self.root.join(STATE_DIR).join("followups.done.md")
3565 }
3566
3567 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
3569 self.root
3570 .join(STATE_DIR)
3571 .join("state")
3572 .join(format!("checkin-{number}.json"))
3573 }
3574
3575 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
3590 let path = self.followups_path();
3591 let heading = format!("## {}", title.trim());
3592 for seen in [&path, &self.worked_followups_path()] {
3593 if let Ok(existing) = std::fs::read_to_string(seen) {
3594 if existing.contains(&heading) {
3595 logdim!("follow-up already noted: {title}");
3596 return Followup::Covered(format!("note: {}", title.trim()));
3597 }
3598 }
3599 }
3600 if let Some(parent) = path.parent() {
3601 let _ = std::fs::create_dir_all(parent);
3602 }
3603 use std::io::Write;
3604 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
3611 match std::fs::OpenOptions::new()
3612 .create(true)
3613 .append(true)
3614 .open(&path)
3615 {
3616 Ok(mut file) => match file.write_all(entry.as_bytes()) {
3617 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
3618 Err(e) => {
3619 logdim!("could not write {}: {e}", path.display());
3620 Followup::Failed
3621 }
3622 },
3623 Err(e) => {
3624 logdim!("could not write {}: {e}", path.display());
3625 Followup::Failed
3626 }
3627 }
3628 }
3629
3630 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
3635 let path = self.worked_followups_path();
3636 if let Some(parent) = path.parent() {
3637 let _ = std::fs::create_dir_all(parent);
3638 }
3639 use std::io::Write;
3640 let entry = format!(
3641 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
3642 title.trim(),
3643 body.trim()
3644 );
3645 if let Ok(mut file) = std::fs::OpenOptions::new()
3646 .create(true)
3647 .append(true)
3648 .open(&path)
3649 {
3650 let _ = file.write_all(entry.as_bytes());
3651 }
3652 }
3653
3654 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
3663 self.root
3664 .join(STATE_DIR)
3665 .join("reviews")
3666 .join(format!("pr-{number}.md"))
3667 }
3668
3669 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
3675 let path = self.pending_comment_path(number);
3676 if let Some(parent) = path.parent() {
3677 std::fs::create_dir_all(parent)
3678 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
3679 }
3680 std::fs::write(&path, text)
3681 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
3682 Ok(path)
3683 }
3684
3685 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
3686 std::fs::read_to_string(self.pending_comment_path(number)).ok()
3687 }
3688
3689 pub fn state_path(&self, number: i64) -> PathBuf {
3690 self.root
3691 .join(STATE_DIR)
3692 .join("state")
3693 .join(format!("pr-{number}.json"))
3694 }
3695
3696 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
3697 let path = self.state_path(number);
3698 let text = std::fs::read_to_string(&path).ok()?;
3699 match serde_json::from_str(&text) {
3700 Ok(state) => Some(state),
3701 Err(_) => {
3702 logdim!("could not read {}, starting fresh", path.display());
3703 None
3704 }
3705 }
3706 }
3707
3708 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
3709 if let Some(local) = self.read_local_state(pr.number) {
3710 return Some(local);
3711 }
3712 if self.state_store.writes_pr() {
3713 return self.read_pr_state(pr.number);
3714 }
3715 None
3716 }
3717
3718 pub(crate) fn read_state_for_head(
3719 &self,
3720 pr: &PrView,
3721 actual_head: &str,
3722 ) -> Option<PersistedState> {
3723 let local = self
3724 .state_store
3725 .writes_local()
3726 .then(|| self.read_local_state(pr.number))
3727 .flatten();
3728 let remote = self
3729 .state_store
3730 .writes_pr()
3731 .then(|| self.read_pr_state(pr.number))
3732 .flatten();
3733 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
3734 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
3735 self.remember_checkpoint(pr.number, checkpoint);
3736 }
3737 choose_state_for_head(candidates, actual_head)
3738 }
3739
3740 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
3741 self.try_read_pr_state(number).ok().flatten()
3742 }
3743
3744 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
3745 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
3746 if let Some(state) = parse_state_comment(&body) {
3747 return Ok(Some(state));
3748 }
3749 }
3750 Ok(None)
3751 }
3752
3753 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
3754 let remote_state = if self.state_store.writes_pr() {
3755 self.try_read_pr_state(number)
3756 } else {
3757 Ok(None)
3758 };
3759 self.write_state_after_remote_read(number, state, remote_state)
3760 }
3761
3762 fn write_state_after_remote_read(
3763 &self,
3764 number: i64,
3765 state: &PersistedState,
3766 remote_state: Result<Option<PersistedState>>,
3767 ) -> Result<()> {
3768 let remote_checkpoint = if self.state_store.writes_pr() {
3769 self.record_failed_write(remote_state)?
3770 .map(|saved| saved.checkpoint)
3771 .unwrap_or_default()
3772 } else {
3773 0
3774 };
3775 let local_checkpoint = self
3776 .state_store
3777 .writes_local()
3778 .then(|| self.read_local_state(number))
3779 .flatten()
3780 .map(|saved| saved.checkpoint)
3781 .unwrap_or_default();
3782 let mut stamped = state.clone();
3783 stamped.checkpoint = state
3784 .checkpoint
3785 .max(local_checkpoint)
3786 .max(remote_checkpoint)
3787 .max(self.remembered_checkpoint(number))
3788 .saturating_add(1);
3789 self.remember_checkpoint(number, stamped.checkpoint);
3790 if self.state_store.writes_local() {
3791 write_json_atomic(&self.state_path(number), &stamped)?;
3792 }
3793 if self.state_store.writes_pr() {
3794 self.write_pr_state(number, &stamped)?;
3795 }
3796 Ok(())
3797 }
3798
3799 fn remembered_checkpoint(&self, number: i64) -> u64 {
3800 self.checkpoints
3801 .lock()
3802 .unwrap_or_else(std::sync::PoisonError::into_inner)
3803 .get(&number)
3804 .copied()
3805 .unwrap_or_default()
3806 }
3807
3808 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
3809 let mut checkpoints = self
3810 .checkpoints
3811 .lock()
3812 .unwrap_or_else(std::sync::PoisonError::into_inner);
3813 let saved = checkpoints.entry(number).or_default();
3814 *saved = (*saved).max(checkpoint);
3815 }
3816
3817 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
3818 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
3822 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
3823 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
3824 if let Some(id) = comment_id {
3825 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
3826 let field = format!("body={body}");
3827 let written = self
3828 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
3829 .map(|_| ());
3830 return self.record_write(written);
3831 }
3832 let written = self
3833 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
3834 .map(|_| ());
3835 self.record_write(written)
3836 }
3837
3838 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
3846 self.try_issue_comments(number).unwrap_or_default()
3847 }
3848
3849 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
3850 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
3851 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
3852 }
3853
3854 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
3855 Ok(self
3856 .try_issue_comments(number)?
3857 .into_iter()
3858 .filter_map(|c| {
3859 let body = c.get("body").and_then(Value::as_str)?.to_string();
3860 if !body.contains("spar:state") {
3861 return None;
3862 }
3863 let id = c.get("id").and_then(Value::as_i64)?;
3864 Some((id, body))
3865 })
3866 .collect())
3867 }
3868
3869 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
3870 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
3871 }
3872
3873 pub fn clear_state(&self, number: i64) {
3875 let path = self.state_path(number);
3876 let _ = std::fs::remove_file(&path);
3877 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
3878 }
3879
3880 pub fn prune_state(&self) -> Vec<String> {
3884 let base = self.root.join(STATE_DIR).join("state");
3885 let Ok(entries) = std::fs::read_dir(&base) else {
3886 return Vec::new();
3887 };
3888 let mut names: Vec<String> = entries
3889 .flatten()
3890 .filter_map(|e| e.file_name().to_str().map(str::to_string))
3891 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
3892 .collect();
3893 names.sort();
3894
3895 let mut removed = Vec::new();
3896 for name in names {
3897 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
3898 continue;
3899 };
3900 if is_finished(&self.pr_state(number)) {
3901 let _ = std::fs::remove_file(base.join(&name));
3902 removed.push(format!("state {name}"));
3903 }
3904 }
3905 removed
3906 }
3907
3908 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
3912 #[derive(Deserialize)]
3913 struct Row {
3914 number: i64,
3915 }
3916 let numbers = match numbers {
3917 Some(numbers) => numbers,
3918 None => {
3919 let listed: Result<Vec<i64>> = (|| {
3920 let text = self.gh(&[
3921 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
3922 ])?;
3923 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3924 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
3925 Ok(rows.into_iter().map(|row| row.number).collect())
3926 })();
3927 match self.record_failed_write(listed) {
3928 Ok(numbers) => numbers,
3929 Err(e) => {
3930 logdim!("could not inspect pull requests for state cleanup: {e}");
3931 return Vec::new();
3932 }
3933 }
3934 }
3935 };
3936
3937 let mut removed = Vec::new();
3938 for number in numbers {
3939 let state = match self.record_failed_write(self.try_pr_state(number)) {
3940 Ok(state) => state,
3941 Err(e) => {
3942 logdim!("could not inspect PR #{number} for state cleanup: {e}");
3943 continue;
3944 }
3945 };
3946 if !is_finished(&state) {
3947 continue;
3948 }
3949 let comments = match self.record_failed_write(self.try_state_comments(number)) {
3950 Ok(comments) => comments,
3951 Err(e) => {
3952 logdim!("could not inspect state comments on PR #{number}: {e}");
3953 continue;
3954 }
3955 };
3956 for (id, _) in comments {
3957 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
3958 let deleted = self
3959 .gh(&["api", "-X", "DELETE", &path, "--silent"])
3960 .map(|_| ());
3961 match self.record_write(deleted) {
3962 Ok(()) => removed.push(format!("state comment on PR #{number}")),
3963 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
3964 }
3965 }
3966 }
3967 removed
3968 }
3969
3970 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
3977 let base = self.root.join(WORKTREE_DIR);
3978 let mut removed = Vec::new();
3979 let known = self.known_branches();
3980
3981 if let Ok(entries) = std::fs::read_dir(&base) {
3982 let mut names: Vec<String> = entries
3983 .flatten()
3984 .filter(|e| e.path().is_dir())
3985 .filter_map(|e| e.file_name().to_str().map(str::to_string))
3986 .collect();
3987 names.sort();
3988
3989 for name in names {
3990 if let Some(rest) = name.strip_prefix("review-") {
3993 let number: i64 = rest.parse().unwrap_or(-1);
3994 if !(force_all || is_finished(&self.pr_state(number))) {
3995 continue;
3996 }
3997 let path = base.join(&name);
3998 if force_all {
3999 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4000 if !belongs {
4001 return Ok(false);
4002 }
4003 let local_ref = review_ref(number);
4004 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4005 return Ok(false);
4006 }
4007 let head = self.head_oid_checked(&path)?;
4008 let recorded = self
4009 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4010 .trim()
4011 .to_string();
4012 Ok(head == recorded)
4013 });
4014 match owned {
4015 Ok(true) => {}
4016 Ok(false) => {
4017 logdim!(
4018 "kept {} because no matching SPAR review reference proves \
4019 ownership",
4020 path.display()
4021 );
4022 continue;
4023 }
4024 Err(e) => {
4025 logdim!(
4026 "kept {} because review ownership could not be verified: {}",
4027 path.display(),
4028 e.last_line()
4029 );
4030 continue;
4031 }
4032 }
4033 } else {
4034 if let Err(e) = self.refuse_review_worktree_changes(number) {
4035 logdim!(
4036 "kept {} because its review state could not be verified as \
4037 disposable: {}",
4038 path.display(),
4039 e.last_line()
4040 );
4041 continue;
4042 }
4043 }
4044 if force_all {
4045 if self.remove_worktree_at_force(&path) {
4046 self.git_try(&["update-ref", "-d", &review_ref(number)]);
4047 }
4048 } else {
4049 self.release_review_worktree(number);
4050 }
4051 if !path.exists() {
4052 removed.push(name);
4053 }
4054 continue;
4055 }
4056 let branch = format!("{}{name}", self.branch_prefix);
4057 if !(force_all || self.worktree_is_done(&branch)) {
4058 continue;
4059 }
4060 if !known.contains_key(&branch) {
4061 logdim!("kept {branch} because it has no branch record");
4062 continue;
4063 }
4064 let path = base.join(&name);
4065 if !force_all {
4066 match self.has_recoverable_work(&path) {
4067 Ok(true) => {
4068 logdim!(
4069 "kept {} because it contains uncommitted changes or ignored files",
4070 path.display()
4071 );
4072 continue;
4073 }
4074 Err(e) => {
4075 logdim!(
4076 "kept {} because its Git state could not be checked: {}",
4077 path.display(),
4078 e.last_line()
4079 );
4080 continue;
4081 }
4082 Ok(false) => {}
4083 }
4084 match self.branch_deletion_is_safe(&branch) {
4085 Ok(true) => {}
4086 Ok(false) => {
4087 logdim!(
4088 "kept {branch} because no surviving ref preserves its tip or \
4089 reflog-only commits"
4090 );
4091 continue;
4092 }
4093 Err(e) => {
4094 logdim!(
4095 "kept {branch} because preservation could not be verified: {}",
4096 e.last_line()
4097 );
4098 continue;
4099 }
4100 }
4101 }
4102 let removed_worktree = if force_all {
4103 self.remove_worktree_at_force(&path)
4104 } else {
4105 match self.remove_worktree_at(&path) {
4106 Ok(removed) => removed,
4107 Err(error) => {
4108 logdim!(
4109 "kept {branch} and {} because removal did not reach a confirmed \
4110 quiet point: {}",
4111 path.display(),
4112 error.last_line()
4113 );
4114 false
4115 }
4116 }
4117 };
4118 if !removed_worktree {
4119 continue;
4120 }
4121 if force_all {
4122 self.git_try(&["branch", "-D", &branch]);
4123 self.forget_branch(&branch);
4124 } else {
4125 match self.delete_branch_if_safe(&branch) {
4126 Ok(true) => self.forget_branch(&branch),
4127 Ok(false) => logdim!(
4128 "kept {branch} because its tip or reflog changed before deletion"
4129 ),
4130 Err(error) => logdim!(
4131 "kept {branch} because deletion safety could not be rechecked: {}",
4132 error.last_line()
4133 ),
4134 }
4135 }
4136 removed.push(name);
4137 }
4138 }
4139 removed.extend(self.prune_branches(force_all));
4140 removed
4141 }
4142
4143 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
4150 let known = self.known_branches();
4151 let branches: Vec<String> = known.keys().cloned().collect();
4152 if branches.is_empty() {
4153 return Vec::new();
4154 }
4155
4156 let checked_out: Vec<String> = self
4157 .git_try(&["worktree", "list", "--porcelain"])
4158 .lines()
4159 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
4160 .collect();
4161
4162 let existing: Vec<String> = self
4165 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
4166 .lines()
4167 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
4168 .collect();
4169
4170 let mut removed = Vec::new();
4171 for branch in branches {
4172 if !existing.contains(&branch) {
4173 self.forget_branch(&branch); continue;
4175 }
4176 if checked_out.contains(&branch) {
4177 continue;
4178 }
4179 if !(force_all || self.worktree_is_done(&branch)) {
4180 continue;
4181 }
4182 if !force_all {
4183 let Some(_record) = known.get(&branch) else {
4184 continue;
4185 };
4186 match self.branch_deletion_is_safe(&branch) {
4187 Ok(true) => {}
4188 Ok(false) => {
4189 logdim!(
4190 "kept {branch} because no surviving ref preserves its tip or \
4191 reflog-only commits"
4192 );
4193 continue;
4194 }
4195 Err(e) => {
4196 logdim!(
4197 "kept {branch} because preservation could not be verified: {}",
4198 e.last_line()
4199 );
4200 continue;
4201 }
4202 }
4203 }
4204 let deleted = if force_all {
4205 self.git(&["branch", "-D", &branch]).map(|_| true)
4206 } else {
4207 self.delete_branch_if_safe(&branch)
4208 };
4209 match deleted {
4210 Ok(true) => {
4211 self.forget_branch(&branch);
4212 removed.push(format!("branch {branch}"));
4213 }
4214 Ok(false) => {
4215 logdim!("kept {branch} because its tip or reflog changed before deletion");
4216 }
4217 Err(e) => {
4218 logdim!("could not delete {branch}: {}", e.last_line());
4221 }
4222 }
4223 }
4224 removed
4225 }
4226
4227 fn worktree_is_done(&self, branch: &str) -> bool {
4229 #[derive(Deserialize)]
4230 struct Row {
4231 state: String,
4232 }
4233 let entry = branch
4234 .strip_prefix(self.branch_prefix.as_str())
4235 .unwrap_or(branch);
4236 if let Some(rest) = entry.strip_prefix("pr-") {
4237 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
4238 }
4239 if entry.starts_with("issue-") || entry.starts_with("split-") {
4243 let text = self.gh_try(&[
4244 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
4245 ]);
4246 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4247 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
4248 }
4249 false
4250 }
4251}
4252
4253pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
4263 let root = std::fs::canonicalize(cwd)
4264 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
4265 let mut files = BTreeMap::new();
4266 let mut visited = BTreeSet::new();
4267 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
4268 Ok(AttributeState { files })
4269}
4270
4271fn collect_attribute_files(
4272 root: &Path,
4273 repository: &Path,
4274 prefix: &Path,
4275 visited: &mut BTreeSet<PathBuf>,
4276 files: &mut BTreeMap<PathBuf, [u8; 32]>,
4277) -> Result<()> {
4278 let canonical = std::fs::canonicalize(repository)
4279 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
4280 if !visited.insert(canonical) {
4281 bail!("submodule recursion revisited {}", repository.display());
4282 }
4283 let entries = index_entries(repository)?;
4284 let mut paths: BTreeSet<PathBuf> = entries
4285 .iter()
4286 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
4287 .map(|entry| entry.path.clone())
4288 .collect();
4289 let untracked = run_git_bytes(
4290 repository,
4291 &[
4292 "ls-files",
4293 "--others",
4294 "-z",
4295 "--",
4296 ".gitattributes",
4297 ":(glob)**/.gitattributes",
4298 ],
4299 )?;
4300 if !untracked.is_empty() && !untracked.ends_with(&[0]) {
4301 bail!(
4302 "git returned an unterminated attribute-file listing for {}",
4303 repository.display()
4304 );
4305 }
4306 for raw in untracked
4307 .split(|byte| *byte == 0)
4308 .filter(|record| !record.is_empty())
4309 {
4310 paths.insert(safe_git_path(raw, "attribute")?);
4311 }
4312 for path in paths {
4313 let from_root = prefix.join(&path);
4314 let state = attribute_file_fingerprint(&root.join(&from_root))?;
4315 files.insert(from_root, state);
4316 }
4317 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
4318 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
4319 continue;
4320 };
4321 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
4322 }
4323 Ok(())
4324}
4325
4326pub(crate) fn uncertain_worktree_change(
4329 cwd: &Path,
4330 message: impl Into<String>,
4331) -> crate::error::SparError {
4332 let message = message.into();
4333 let marker = write_recovery_marker(cwd, &message);
4334 let note = match marker {
4335 Ok(path) => format!(" Recovery marker: {}.", path.display()),
4336 Err(e) => format!(
4337 " A recovery marker could not be written: {}.",
4338 e.last_line()
4339 ),
4340 };
4341 crate::error::SparError::uncertain_write(format!("{message}{note}"))
4342}
4343
4344fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
4345 use std::sync::atomic::{AtomicU32, Ordering};
4346 static NEXT: AtomicU32 = AtomicU32::new(0);
4347 for _ in 0..1000 {
4348 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
4349 let path = cwd.join(format!(
4350 ".spar-recovery-needed-{}-{serial}",
4351 std::process::id()
4352 ));
4353 let mut options = OpenOptions::new();
4354 options.write(true).create_new(true);
4355 #[cfg(unix)]
4356 {
4357 use std::os::unix::fs::OpenOptionsExt;
4358 options.mode(0o600);
4359 }
4360 match options.open(&path) {
4361 Ok(mut file) => {
4362 file.write_all(detail.as_bytes())
4363 .and_then(|_| file.write_all(b"\n"))
4364 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4365 return Ok(path);
4366 }
4367 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
4368 Err(e) => {
4369 return Err(spar_err!(
4370 "could not create a recovery marker in {}: {e}",
4371 cwd.display()
4372 ))
4373 }
4374 }
4375 }
4376 bail!(
4377 "could not choose a free recovery marker name in {}",
4378 cwd.display()
4379 )
4380}
4381
4382fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
4387 let mut argv = vec![
4388 "git".to_string(),
4389 "-c".to_string(),
4390 "maintenance.auto=false".to_string(),
4391 "-c".to_string(),
4392 "gc.auto=0".to_string(),
4393 ];
4394 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4395 argv
4396}
4397
4398fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
4399 let mut argv = git_without_maintenance_argv(&[]);
4400 argv.extend([
4401 "-c".to_string(),
4402 "core.fsmonitor=".to_string(),
4403 "-c".to_string(),
4404 "commit.gpgsign=false".to_string(),
4405 "-c".to_string(),
4406 "core.hooksPath=/dev/null".to_string(),
4407 ]);
4408 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4409 argv
4410}
4411
4412pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
4419 let root = std::fs::canonicalize(cwd)
4420 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
4421 let mut files = BTreeMap::new();
4422 let mut ignored = BTreeSet::new();
4423 let mut visited = BTreeSet::new();
4424 collect_untracked_files(
4425 &root,
4426 &root,
4427 Path::new(""),
4428 &mut visited,
4429 &mut files,
4430 &mut ignored,
4431 )?;
4432 Ok(IgnoredState { files, ignored })
4433}
4434
4435fn collect_untracked_files(
4436 root: &Path,
4437 repository: &Path,
4438 prefix: &Path,
4439 visited: &mut BTreeSet<PathBuf>,
4440 files: &mut BTreeMap<PathBuf, UntrackedFile>,
4441 ignored: &mut BTreeSet<PathBuf>,
4442) -> Result<()> {
4443 let canonical = std::fs::canonicalize(repository)
4444 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
4445 if !visited.insert(canonical.clone()) {
4446 bail!("submodule recursion revisited {}", canonical.display());
4447 }
4448 let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
4449 if !listed.is_empty() && !listed.ends_with(&[0]) {
4450 bail!(
4451 "git returned an unterminated untracked-file list for {}",
4452 repository.display()
4453 );
4454 }
4455
4456 for raw in listed
4457 .split(|byte| *byte == 0)
4458 .filter(|raw| !raw.is_empty())
4459 {
4460 let relative = safe_git_path(raw, "untracked")?;
4461 let from_root = prefix.join(&relative);
4462 let fingerprint = ignored_file_fingerprint(&root.join(&from_root))?;
4463 if files.insert(from_root.clone(), fingerprint).is_some() {
4464 bail!(
4465 "git returned the untracked path more than once: {:?}",
4466 from_root
4467 );
4468 }
4469 }
4470
4471 let ignored_listed = run_git_bytes(
4472 repository,
4473 &[
4474 "ls-files",
4475 "--others",
4476 "--ignored",
4477 "--exclude-standard",
4478 "-z",
4479 ],
4480 )?;
4481 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
4482 bail!(
4483 "git returned an unterminated ignored-file list for {}",
4484 repository.display()
4485 );
4486 }
4487 for raw in ignored_listed
4488 .split(|byte| *byte == 0)
4489 .filter(|raw| !raw.is_empty())
4490 {
4491 let relative = safe_git_path(raw, "ignored")?;
4492 let from_root = prefix.join(relative);
4493 if !files.contains_key(&from_root) {
4494 bail!(
4495 "git classified an unlisted path as ignored: {:?}",
4496 from_root
4497 );
4498 }
4499 if !ignored.insert(from_root.clone()) {
4500 bail!(
4501 "git returned the ignored path more than once: {:?}",
4502 from_root
4503 );
4504 }
4505 }
4506
4507 for link in gitlinks(repository)? {
4508 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
4509 continue;
4510 };
4511 collect_untracked_files(
4512 root,
4513 &submodule,
4514 &prefix.join(&link.path),
4515 visited,
4516 files,
4517 ignored,
4518 )?;
4519 }
4520 Ok(())
4521}
4522
4523fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
4524 let argv = git_without_automation_argv(args);
4525 proc::run_bytes(
4526 &argv,
4527 &ExecOpts::new()
4528 .cwd(cwd)
4529 .timeout_secs(30)
4530 .stop_descendants(true),
4531 )
4532}
4533
4534fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
4535 let argv = git_without_automation_argv(args);
4536 proc::run(
4537 &argv,
4538 &ExecOpts::new()
4539 .cwd(cwd)
4540 .timeout_secs(30)
4541 .stop_descendants(true),
4542 )
4543}
4544
4545fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
4546 let path = path.to_str().ok_or_else(|| {
4547 spar_err!(
4548 "cannot verify filtered content for a non-UTF-8 path in {}",
4549 cwd.display()
4550 )
4551 })?;
4552 let path_arg = format!("--path={path}");
4553 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
4554 Ok(Sha256::digest(bytes).into())
4555}
4556
4557fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
4558 let relative = path_from_git_bytes(raw)?;
4559 if relative.is_absolute()
4560 || relative.components().any(|component| {
4561 matches!(
4562 component,
4563 std::path::Component::ParentDir
4564 | std::path::Component::RootDir
4565 | std::path::Component::Prefix(_)
4566 )
4567 })
4568 {
4569 bail!("git returned an unsafe {kind} path: {:?}", relative);
4570 }
4571 Ok(relative)
4572}
4573
4574fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
4575 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
4576 if !listed.is_empty() && !listed.ends_with(&[0]) {
4577 bail!(
4578 "git returned an unterminated index listing for {}",
4579 cwd.display()
4580 );
4581 }
4582 let mut entries = Vec::new();
4583 for record in listed
4584 .split(|byte| *byte == 0)
4585 .filter(|record| !record.is_empty())
4586 {
4587 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
4588 bail!(
4589 "git returned a malformed index record for {}",
4590 cwd.display()
4591 );
4592 };
4593 let header = &record[..tab];
4594 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
4595 if fields.len() != 3 {
4596 bail!(
4597 "git returned a malformed index header for {}",
4598 cwd.display()
4599 );
4600 }
4601 if fields[2] != b"0" {
4602 continue;
4603 }
4604 let mode = std::str::from_utf8(fields[0])
4605 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
4606 .to_string();
4607 let oid = std::str::from_utf8(fields[1])
4608 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
4609 .to_string();
4610 entries.push(IndexEntry {
4611 path: safe_git_path(&record[tab + 1..], "index")?,
4612 mode,
4613 oid,
4614 });
4615 }
4616 Ok(entries)
4617}
4618
4619fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
4620 let untracked = run_git_bytes(
4621 cwd,
4622 &[
4623 "ls-files",
4624 "--others",
4625 "-z",
4626 "--",
4627 ".gitattributes",
4628 ":(glob)**/.gitattributes",
4629 ],
4630 )?;
4631 if !untracked.is_empty() {
4632 return Ok(true);
4633 }
4634
4635 let index = index_entries(cwd)?
4636 .into_iter()
4637 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
4638 .map(|entry| (entry.path, (entry.mode, entry.oid)))
4639 .collect::<BTreeMap<_, _>>();
4640 let head = tree_entries(cwd, "HEAD")?
4641 .into_iter()
4642 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
4643 .map(|entry| (entry.path, (entry.mode, entry.oid)))
4644 .collect::<BTreeMap<_, _>>();
4645 if index != head {
4646 return Ok(true);
4647 }
4648
4649 let effective = check_attributes(cwd, index.keys().cloned())?;
4650 for (path, (_mode, oid)) in index {
4651 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
4652 return Ok(true);
4653 };
4654 let attributes = effective
4655 .get(&path)
4656 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
4657 if allows_expected_crlf(cwd, attributes)? {
4658 if worktree.mode == "120000" {
4659 return Ok(true);
4660 }
4661 let (normalized, every_lf_was_crlf) =
4662 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
4663 if !every_lf_was_crlf || normalized != oid {
4664 return Ok(true);
4665 }
4666 } else if worktree.raw_oid != oid {
4667 return Ok(true);
4668 }
4669 }
4670 Ok(false)
4671}
4672
4673fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
4674 Ok(index_entries(cwd)?
4675 .into_iter()
4676 .filter(|entry| entry.mode == "160000")
4677 .map(|entry| Gitlink {
4678 path: entry.path,
4679 oid: entry.oid,
4680 })
4681 .collect())
4682}
4683
4684fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
4685 let mut tracked = BTreeMap::new();
4686 for entry in index_entries(cwd)? {
4687 if entry.mode == "160000" {
4688 continue;
4689 }
4690 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
4691 tracked.insert(
4692 entry.path,
4693 TrackedEntry {
4694 index_mode: entry.mode,
4695 index_oid: entry.oid,
4696 worktree,
4697 },
4698 );
4699 }
4700 Ok(tracked)
4701}
4702
4703fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
4704 let metadata = match std::fs::symlink_metadata(path) {
4705 Ok(metadata) => metadata,
4706 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
4707 Err(e) => {
4708 return Err(spar_err!(
4709 "could not inspect tracked file {}: {e}",
4710 path.display()
4711 ))
4712 }
4713 };
4714 let mut fingerprint = Sha256::new();
4715 if metadata.file_type().is_symlink() {
4716 let target = std::fs::read_link(path)
4717 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
4718 let bytes = os_str_bytes(target.as_os_str())?;
4719 fingerprint.update(b"symlink\0");
4720 fingerprint.update(&bytes);
4721 let content = Sha256::digest(&bytes).into();
4722 return Ok(Some(WorktreeFile {
4723 mode: "120000".to_string(),
4724 #[cfg(unix)]
4725 permissions: 0,
4726 raw_oid: git_blob_oid(oid_len, &bytes)?,
4727 fingerprint: fingerprint.finalize().into(),
4728 content,
4729 }));
4730 }
4731 if !metadata.is_file() {
4732 bail!("tracked path {} is not a file or symlink", path.display());
4733 }
4734
4735 let mut options = OpenOptions::new();
4736 options.read(true);
4737 #[cfg(unix)]
4738 {
4739 use std::os::unix::fs::OpenOptionsExt;
4740 options.custom_flags(libc::O_NOFOLLOW);
4741 }
4742 let mut file = options
4743 .open(path)
4744 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
4745 let before = file
4746 .metadata()
4747 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
4748 let mode = tracked_file_mode(&before);
4749 #[cfg(unix)]
4750 let permissions = {
4751 use std::os::unix::fs::MetadataExt;
4752 before.mode() & 0o7777
4753 };
4754 fingerprint.update(b"file\0");
4755 fingerprint.update(mode.as_bytes());
4756 #[cfg(unix)]
4757 fingerprint.update(permissions.to_le_bytes());
4758 fingerprint.update(before.len().to_le_bytes());
4759 let mut content = Sha256::new();
4760 let header = format!("blob {}\0", before.len());
4761 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
4762 let mut buf = [0u8; 64 * 1024];
4763 loop {
4764 let read = file
4765 .read(&mut buf)
4766 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
4767 if read == 0 {
4768 break;
4769 }
4770 fingerprint.update(&buf[..read]);
4771 content.update(&buf[..read]);
4772 object.update(&buf[..read]);
4773 }
4774 let after = file
4775 .metadata()
4776 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4777 if before.len() != after.len()
4778 || before.modified().ok() != after.modified().ok()
4779 || before.permissions() != after.permissions()
4780 {
4781 bail!(
4782 "tracked file {} changed while it was being inspected",
4783 path.display()
4784 );
4785 }
4786 let current = std::fs::symlink_metadata(path)
4787 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4788 if !same_file(&after, ¤t) {
4789 bail!(
4790 "tracked file {} was replaced while it was being inspected",
4791 path.display()
4792 );
4793 }
4794 Ok(Some(WorktreeFile {
4795 mode,
4796 #[cfg(unix)]
4797 permissions,
4798 raw_oid: object.finish(),
4799 fingerprint: fingerprint.finalize().into(),
4800 content: content.finalize().into(),
4801 }))
4802}
4803
4804fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
4805 let metadata = std::fs::symlink_metadata(path)
4806 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
4807 let mut digest = Sha256::new();
4808 if metadata.file_type().is_symlink() {
4809 digest.update(b"symlink\0");
4810 let target = std::fs::read_link(path)
4811 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
4812 digest.update(os_str_bytes(target.as_os_str())?);
4813 return Ok(digest.finalize().into());
4814 }
4815 if !metadata.is_file() {
4816 bail!("attribute path {} is not a file or symlink", path.display());
4817 }
4818 let mut options = OpenOptions::new();
4819 options.read(true);
4820 #[cfg(unix)]
4821 {
4822 use std::os::unix::fs::OpenOptionsExt;
4823 options.custom_flags(libc::O_NOFOLLOW);
4824 }
4825 let mut file = options
4826 .open(path)
4827 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
4828 let before = file
4829 .metadata()
4830 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
4831 digest.update(b"file\0");
4832 let mut buf = [0u8; 64 * 1024];
4833 loop {
4834 let read = file
4835 .read(&mut buf)
4836 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
4837 if read == 0 {
4838 break;
4839 }
4840 digest.update(&buf[..read]);
4841 }
4842 let after = file
4843 .metadata()
4844 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
4845 let current = std::fs::symlink_metadata(path)
4846 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
4847 if before.len() != after.len()
4848 || before.modified().ok() != after.modified().ok()
4849 || !same_file(&after, ¤t)
4850 {
4851 bail!(
4852 "attribute file {} changed while it was being inspected",
4853 path.display()
4854 );
4855 }
4856 Ok(digest.finalize().into())
4857}
4858
4859enum ObjectHasher {
4860 Sha1(Sha1),
4861 Sha256(Sha256),
4862}
4863
4864impl ObjectHasher {
4865 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
4866 let mut hasher = match oid_len {
4867 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
4868 64 => Self::Sha256(Sha256::new()),
4869 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
4870 };
4871 hasher.update(header);
4872 Ok(hasher)
4873 }
4874
4875 fn update(&mut self, bytes: &[u8]) {
4876 match self {
4877 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
4878 Self::Sha256(hasher) => hasher.update(bytes),
4879 }
4880 }
4881
4882 fn finish(self) -> String {
4883 let bytes = match self {
4884 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
4885 Self::Sha256(hasher) => hasher.finalize().to_vec(),
4886 };
4887 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
4888 }
4889}
4890
4891fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
4892 let header = format!("blob {}\0", bytes.len());
4893 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
4894 hasher.update(bytes);
4895 Ok(hasher.finish())
4896}
4897
4898fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
4899 let mut first = open_regular_file(path)?;
4900 let first_before = first
4901 .metadata()
4902 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
4903 let mut raw_len = 0u64;
4904 let mut crlf_pairs = 0u64;
4905 let mut previous_was_cr = false;
4906 let mut every_lf_was_crlf = true;
4907 let mut buf = [0u8; 64 * 1024];
4908 loop {
4909 let read = first
4910 .read(&mut buf)
4911 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
4912 if read == 0 {
4913 break;
4914 }
4915 raw_len = raw_len
4916 .checked_add(read as u64)
4917 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
4918 for byte in &buf[..read] {
4919 if *byte == b'\n' {
4920 if previous_was_cr {
4921 crlf_pairs += 1;
4922 } else {
4923 every_lf_was_crlf = false;
4924 }
4925 }
4926 previous_was_cr = *byte == b'\r';
4927 }
4928 }
4929 let first_after = first
4930 .metadata()
4931 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4932 let current = std::fs::symlink_metadata(path)
4933 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4934 if raw_len != first_before.len()
4935 || !stable_file_metadata(&first_before, &first_after)
4936 || !stable_file_metadata(&first_after, ¤t)
4937 {
4938 bail!(
4939 "tracked file {} changed while line endings were inspected",
4940 path.display()
4941 );
4942 }
4943
4944 let normalized_len = raw_len
4945 .checked_sub(crlf_pairs)
4946 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
4947 let header = format!("blob {normalized_len}\0");
4948 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
4949 let mut second = open_regular_file(path)?;
4950 let second_before = second
4951 .metadata()
4952 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
4953 if !stable_file_metadata(&first_after, &second_before) {
4954 bail!(
4955 "tracked file {} changed between line-ending checks",
4956 path.display()
4957 );
4958 }
4959 let mut pending_cr = false;
4960 loop {
4961 let read = second
4962 .read(&mut buf)
4963 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
4964 if read == 0 {
4965 break;
4966 }
4967 for byte in &buf[..read] {
4968 if pending_cr {
4969 if *byte == b'\n' {
4970 object.update(b"\n");
4971 pending_cr = false;
4972 continue;
4973 }
4974 object.update(b"\r");
4975 pending_cr = false;
4976 }
4977 if *byte == b'\r' {
4978 pending_cr = true;
4979 } else {
4980 object.update(std::slice::from_ref(byte));
4981 }
4982 }
4983 }
4984 if pending_cr {
4985 object.update(b"\r");
4986 }
4987 let second_after = second
4988 .metadata()
4989 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4990 let current = std::fs::symlink_metadata(path)
4991 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4992 if !stable_file_metadata(&second_before, &second_after)
4993 || !stable_file_metadata(&second_after, ¤t)
4994 {
4995 bail!(
4996 "tracked file {} changed while line endings were hashed",
4997 path.display()
4998 );
4999 }
5000 Ok((object.finish(), every_lf_was_crlf))
5001}
5002
5003fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5004 let mut options = OpenOptions::new();
5005 options.read(true);
5006 #[cfg(unix)]
5007 {
5008 use std::os::unix::fs::OpenOptionsExt;
5009 options.custom_flags(libc::O_NOFOLLOW);
5010 }
5011 let file = options
5012 .open(path)
5013 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5014 let metadata = file
5015 .metadata()
5016 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5017 if !metadata.is_file() {
5018 bail!("tracked path {} is not a regular file", path.display());
5019 }
5020 Ok(file)
5021}
5022
5023fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5024 if !same_file(left, right)
5025 || left.len() != right.len()
5026 || left.modified().ok() != right.modified().ok()
5027 || left.permissions() != right.permissions()
5028 {
5029 return false;
5030 }
5031 #[cfg(unix)]
5032 {
5033 use std::os::unix::fs::MetadataExt;
5034 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5035 }
5036 #[cfg(not(unix))]
5037 {
5038 left.created().ok() == right.created().ok()
5039 }
5040}
5041
5042fn check_attributes(
5043 cwd: &Path,
5044 paths: impl IntoIterator<Item = PathBuf>,
5045) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5046 const NAMES: [&str; 6] = [
5047 "filter",
5048 "working-tree-encoding",
5049 "ident",
5050 "text",
5051 "eol",
5052 "crlf",
5053 ];
5054 let paths = paths.into_iter().collect::<BTreeSet<_>>();
5055 if paths.is_empty() {
5056 return Ok(BTreeMap::new());
5057 }
5058 let mut input = String::new();
5059 for path in &paths {
5060 let path = path.to_str().ok_or_else(|| {
5061 spar_err!(
5062 "cannot inspect attributes for a non-UTF-8 path in {}",
5063 cwd.display()
5064 )
5065 })?;
5066 input.push_str(path);
5067 input.push('\0');
5068 }
5069 let argv = git_without_automation_argv(&[
5070 "check-attr",
5071 "-z",
5072 "--cached",
5073 "--stdin",
5074 "filter",
5075 "working-tree-encoding",
5076 "ident",
5077 "text",
5078 "eol",
5079 "crlf",
5080 ]);
5081 let output = proc::run_bytes(
5082 &argv,
5083 &ExecOpts::new()
5084 .cwd(cwd)
5085 .timeout_secs(30)
5086 .stdin(input)
5087 .stop_descendants(true),
5088 )?;
5089 if !output.is_empty() && !output.ends_with(&[0]) {
5090 bail!(
5091 "git returned an unterminated attribute result for {}",
5092 cwd.display()
5093 );
5094 }
5095 let fields = output
5096 .split(|byte| *byte == 0)
5097 .filter(|field| !field.is_empty())
5098 .collect::<Vec<_>>();
5099 if fields.len() != paths.len() * NAMES.len() * 3 {
5100 bail!(
5101 "git returned an unexpected attribute result for {}",
5102 cwd.display()
5103 );
5104 }
5105 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5106 for record in fields.chunks_exact(3) {
5107 let path = safe_git_path(record[0], "attribute")?;
5108 if !paths.contains(&path) {
5109 bail!(
5110 "git returned attributes for the wrong path in {}",
5111 cwd.display()
5112 );
5113 }
5114 let name = std::str::from_utf8(record[1])
5115 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
5116 let value = std::str::from_utf8(record[2])
5117 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
5118 values
5119 .entry(path)
5120 .or_default()
5121 .insert(name.to_string(), value.to_string());
5122 }
5123 if paths.iter().any(|path| {
5124 values
5125 .get(path)
5126 .is_none_or(|attributes| attributes.len() != NAMES.len())
5127 }) {
5128 bail!(
5129 "git omitted an attribute result for a tracked path in {}",
5130 cwd.display()
5131 );
5132 }
5133 Ok(values)
5134}
5135
5136fn attribute_is_active(value: Option<&String>) -> bool {
5137 !matches!(
5138 value.map(String::as_str),
5139 None | Some("unspecified") | Some("unset")
5140 )
5141}
5142
5143fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
5144 attribute_is_active(values.get("filter"))
5145 || attribute_is_active(values.get("working-tree-encoding"))
5146}
5147
5148fn path_has_ambiguous_transform(cwd: &Path, values: &BTreeMap<String, String>) -> Result<bool> {
5149 if path_has_external_transform(values)
5150 || attribute_is_active(values.get("ident"))
5151 || attribute_is_active(values.get("crlf"))
5152 {
5153 return Ok(true);
5154 }
5155 let text = values.get("text").map(String::as_str);
5156 let eol = values.get("eol").map(String::as_str);
5157 if text == Some("auto") {
5158 return Ok(true);
5159 }
5160 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
5161 || !matches!(
5162 eol,
5163 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
5164 )
5165 {
5166 return Ok(true);
5167 }
5168 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
5169 return Ok(
5170 git_config_value(cwd, "core.autocrlf")?.is_some_and(|value| {
5171 matches!(
5172 value.to_ascii_lowercase().as_str(),
5173 "true" | "yes" | "on" | "1"
5174 )
5175 }),
5176 );
5177 }
5178 Ok(false)
5179}
5180
5181fn allows_expected_crlf(cwd: &Path, values: &BTreeMap<String, String>) -> Result<bool> {
5182 if path_has_external_transform(values)
5183 || attribute_is_active(values.get("ident"))
5184 || attribute_is_active(values.get("crlf"))
5185 {
5186 return Ok(false);
5187 }
5188 let text = values.get("text").map(String::as_str);
5189 let eol = values.get("eol").map(String::as_str);
5190 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
5191 return Ok(false);
5192 }
5193 if eol == Some("crlf") {
5194 return Ok(true);
5195 }
5196 if text != Some("set") {
5197 return Ok(false);
5198 }
5199 if let Some(autocrlf) = git_config_value(cwd, "core.autocrlf")? {
5200 match autocrlf.to_ascii_lowercase().as_str() {
5201 "true" | "yes" | "on" | "1" => return Ok(true),
5202 "input" => return Ok(false),
5203 _ => {}
5204 }
5205 }
5206 if git_config_value(cwd, "core.eol")?.is_some_and(|value| value.eq_ignore_ascii_case("crlf")) {
5207 return Ok(true);
5208 }
5209 #[cfg(windows)]
5210 if git_config_value(cwd, "core.eol")?.is_none_or(|value| value.eq_ignore_ascii_case("native")) {
5211 return Ok(true);
5212 }
5213 Ok(false)
5214}
5215
5216fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
5217 let argv = git_without_automation_argv(&["config", "--get", key]);
5218 let output = proc::exec(
5219 &argv,
5220 &ExecOpts::new()
5221 .cwd(cwd)
5222 .timeout_secs(30)
5223 .check(false)
5224 .stop_descendants(true),
5225 )?;
5226 match output.code {
5227 0 => Ok(Some(output.stdout.trim().to_string())),
5228 1 => Ok(None),
5229 _ => bail!(
5230 "could not read Git configuration in {}: {}",
5231 cwd.display(),
5232 output.stderr.trim()
5233 ),
5234 }
5235}
5236
5237fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
5238 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
5239 let output = proc::exec(
5240 &argv,
5241 &ExecOpts::new()
5242 .cwd(cwd)
5243 .timeout_secs(30)
5244 .check(false)
5245 .stop_descendants(true),
5246 )?;
5247 match output.code {
5248 0 if output.stdout.trim() == "true" => Ok(Some(true)),
5249 0 if output.stdout.trim() == "false" => Ok(Some(false)),
5250 0 => bail!(
5251 "git returned an invalid boolean for {key} in {}",
5252 cwd.display()
5253 ),
5254 1 => Ok(None),
5255 _ => bail!(
5256 "could not read Git configuration in {}: {}",
5257 cwd.display(),
5258 output.stderr.trim()
5259 ),
5260 }
5261}
5262
5263#[cfg(unix)]
5264fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
5265 use std::os::unix::fs::PermissionsExt;
5266 if metadata.permissions().mode() & 0o111 == 0 {
5267 "100644".to_string()
5268 } else {
5269 "100755".to_string()
5270 }
5271}
5272
5273#[cfg(not(unix))]
5274fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
5275 "100644".to_string()
5276}
5277
5278fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
5279 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
5280 if !listed.is_empty() && !listed.ends_with(&[0]) {
5281 bail!(
5282 "git returned an unterminated tree listing for {}",
5283 cwd.display()
5284 );
5285 }
5286 let mut entries = Vec::new();
5287 for record in listed
5288 .split(|byte| *byte == 0)
5289 .filter(|record| !record.is_empty())
5290 {
5291 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5292 bail!("git returned a malformed tree record for {}", cwd.display());
5293 };
5294 let fields = record[..tab]
5295 .split(|byte| *byte == b' ')
5296 .collect::<Vec<_>>();
5297 if fields.len() != 3 {
5298 bail!("git returned a malformed tree header for {}", cwd.display());
5299 }
5300 let mode = std::str::from_utf8(fields[0])
5301 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
5302 .to_string();
5303 let oid = std::str::from_utf8(fields[2])
5304 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5305 .to_string();
5306 entries.push(IndexEntry {
5307 path: safe_git_path(&record[tab + 1..], "tree")?,
5308 mode,
5309 oid,
5310 });
5311 }
5312 Ok(entries)
5313}
5314
5315fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
5316 Ok(tree_entries(cwd, "HEAD")?
5317 .into_iter()
5318 .filter(|entry| entry.mode == "160000")
5319 .map(|entry| (entry.path, entry.oid))
5320 .collect())
5321}
5322
5323fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
5324 let head = head_gitlinks(cwd)?;
5325 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
5326 .into_iter()
5327 .map(|link| (link.path, link.oid))
5328 .collect();
5329 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
5330 paths.extend(index.keys().cloned());
5331 Ok(paths
5332 .into_iter()
5333 .filter(|path| head.get(path) != index.get(path))
5334 .collect())
5335}
5336
5337fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
5338 let path = parent.join(relative);
5339 let metadata = match std::fs::symlink_metadata(&path) {
5340 Ok(metadata) => metadata,
5341 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5342 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
5343 };
5344 if !metadata.is_dir() {
5345 bail!("the gitlink at {} is not a directory", path.display());
5346 }
5347 let canonical = std::fs::canonicalize(&path)
5348 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
5349 if canonical != path {
5350 bail!(
5351 "the gitlink at {} resolves through a symlink",
5352 path.display()
5353 );
5354 }
5355 if !path.join(".git").exists() {
5356 let empty = std::fs::read_dir(&path)
5357 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
5358 .next()
5359 .is_none();
5360 if empty {
5361 return Ok(None);
5362 }
5363 bail!(
5364 "the uninitialized gitlink at {} contains local files",
5365 path.display()
5366 );
5367 }
5368 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
5369 if inside.trim() != "true" {
5370 bail!("the gitlink at {} is not a worktree", path.display());
5371 }
5372 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
5373 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
5374 spar_err!(
5375 "could not resolve the gitlink top level at {}: {e}",
5376 path.display()
5377 )
5378 })?;
5379 if top != canonical {
5380 bail!(
5381 "the gitlink at {} belongs to a different worktree",
5382 path.display()
5383 );
5384 }
5385 Ok(Some(canonical))
5386}
5387
5388fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
5389 let root = std::fs::canonicalize(cwd)
5390 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5391 let mut allowed = BTreeSet::from([root.join(".git")]);
5392 let mut repositories = vec![root.clone()];
5393 let mut visited = BTreeSet::new();
5394 while let Some(repository) = repositories.pop() {
5395 let canonical = std::fs::canonicalize(&repository)
5396 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5397 if !visited.insert(canonical.clone()) {
5398 bail!("submodule recursion revisited {}", canonical.display());
5399 }
5400 for link in gitlinks(&canonical)? {
5401 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
5402 continue;
5403 };
5404 allowed.insert(submodule.join(".git"));
5405 repositories.push(submodule);
5406 }
5407 }
5408
5409 let scan_root = root.clone();
5410 let mut directories = vec![root];
5411 while let Some(directory) = directories.pop() {
5412 let entries = std::fs::read_dir(&directory)
5413 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5414 for entry in entries {
5415 let entry =
5416 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5417 let path = entry.path();
5418 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
5419 continue;
5420 }
5421 if entry.file_name() == OsStr::new(".git") {
5422 if !allowed.contains(&path) {
5423 return Ok(Some(path));
5424 }
5425 continue;
5426 }
5427 let kind = entry
5428 .file_type()
5429 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5430 if kind.is_dir() {
5431 directories.push(path);
5432 }
5433 }
5434 }
5435 Ok(None)
5436}
5437
5438pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
5439 if let Some(path) = unexpected_nested_git_entry(cwd)? {
5440 bail!(
5441 "the worktree contains an untracked Git entry at {}. It was kept because its \
5442 repository objects are not represented by the outer index.",
5443 path.display()
5444 );
5445 }
5446 let root = std::fs::canonicalize(cwd)
5447 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5448 let mut repositories = BTreeMap::new();
5449 let mut visited = BTreeSet::new();
5450 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
5451 Ok(GitState { repositories })
5452}
5453
5454fn collect_git_state(
5455 repository: &Path,
5456 prefix: &Path,
5457 visited: &mut BTreeSet<PathBuf>,
5458 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
5459) -> Result<()> {
5460 let canonical = std::fs::canonicalize(repository)
5461 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5462 if !visited.insert(canonical.clone()) {
5463 bail!("submodule recursion revisited {}", canonical.display());
5464 }
5465 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5466 let head = head.trim().to_string();
5467 if head.is_empty() {
5468 bail!("git returned an empty head for {}", repository.display());
5469 }
5470 let unsafe_index_flags = unsafe_index_flags(repository)?;
5471 let tracked = tracked_entries(repository)?;
5472 let gitlinks = gitlinks(repository)?;
5473 if repositories
5474 .insert(
5475 prefix.to_path_buf(),
5476 RepositoryState {
5477 head,
5478 unsafe_index_flags,
5479 tracked,
5480 gitlinks: gitlinks
5481 .iter()
5482 .map(|link| (link.path.clone(), link.oid.clone()))
5483 .collect(),
5484 },
5485 )
5486 .is_some()
5487 {
5488 bail!("Git state contains duplicate repository path {:?}", prefix);
5489 }
5490
5491 for link in gitlinks {
5492 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5493 continue;
5494 };
5495 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
5496 }
5497 Ok(())
5498}
5499
5500fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
5501 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
5502 if !listed.is_empty() && !listed.ends_with(&[0]) {
5503 bail!(
5504 "git returned an unterminated index-flag listing for {}",
5505 cwd.display()
5506 );
5507 }
5508 let mut unsafe_records = Vec::new();
5509 for record in listed
5510 .split(|byte| *byte == 0)
5511 .filter(|record| !record.is_empty())
5512 {
5513 if record.len() < 3 || record[1] != b' ' {
5514 bail!(
5515 "git returned a malformed index-flag record for {}",
5516 cwd.display()
5517 );
5518 }
5519 if record[0] != b'H' {
5520 unsafe_records.extend_from_slice(record);
5521 unsafe_records.push(0);
5522 }
5523 }
5524 Ok(unsafe_records)
5525}
5526
5527pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
5528 safe_git_state(cwd).map(|_| ())
5529}
5530
5531pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
5532 let state = git_state(cwd)?;
5533 if let Some((path, _repository)) = state
5534 .repositories
5535 .iter()
5536 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
5537 {
5538 let label = if path.as_os_str().is_empty() {
5539 cwd.to_path_buf()
5540 } else {
5541 cwd.join(path)
5542 };
5543 bail!(
5544 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
5545 SPAR cannot prove the working files are unchanged, so it was kept.",
5546 label.display()
5547 );
5548 }
5549 Ok(state)
5550}
5551
5552fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
5553 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
5554 return Ok(true);
5555 }
5556 let mut visited = BTreeSet::new();
5557 repository_has_recoverable_work_inner(cwd, include_ignored, &mut visited)
5558}
5559
5560fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
5561 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
5562 let git_dir = PathBuf::from(git_dir.trim());
5563 let git_dir = if git_dir.is_absolute() {
5564 git_dir
5565 } else {
5566 cwd.join(git_dir)
5567 };
5568 let git_dir = std::fs::canonicalize(&git_dir)
5569 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
5570 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
5571 Ok(_) => return Ok(true),
5572 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
5573 Err(error) => {
5574 return Err(spar_err!(
5575 "could not inspect per-worktree configuration in {}: {error}",
5576 git_dir.display()
5577 ))
5578 }
5579 }
5580
5581 let orig_head = git_dir.join("ORIG_HEAD");
5582 match std::fs::symlink_metadata(&orig_head) {
5583 Ok(metadata) if metadata.is_file() => {
5584 let oid = std::fs::read_to_string(&orig_head)
5585 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
5586 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
5587 return Ok(true);
5588 };
5589 if !commit_has_shared_ref(cwd, &commit)? {
5590 return Ok(true);
5591 }
5592 }
5593 Ok(_) => return Ok(true),
5594 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
5595 Err(error) => {
5596 return Err(spar_err!(
5597 "could not inspect {}: {error}",
5598 orig_head.display()
5599 ))
5600 }
5601 }
5602
5603 let edit_message = git_dir.join("COMMIT_EDITMSG");
5604 match std::fs::symlink_metadata(&edit_message) {
5605 Ok(metadata) if metadata.is_file() => {
5606 let draft = std::fs::read(&edit_message)
5607 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
5608 if draft != head_commit_message(cwd)? {
5609 return Ok(true);
5610 }
5611 }
5612 Ok(_) => return Ok(true),
5613 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
5614 Err(error) => {
5615 return Err(spar_err!(
5616 "could not inspect {}: {error}",
5617 edit_message.display()
5618 ))
5619 }
5620 }
5621
5622 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
5623 return Ok(true);
5624 }
5625
5626 let local_refs = run_git_bytes(
5627 cwd,
5628 &[
5629 "for-each-ref",
5630 "--format=%(refname)",
5631 "refs/worktree",
5632 "refs/bisect",
5633 "refs/rewritten",
5634 ],
5635 )?;
5636 if !local_refs.is_empty() {
5637 return Ok(true);
5638 }
5639
5640 for entry in std::fs::read_dir(&git_dir)
5641 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
5642 {
5643 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
5644 let known = matches!(
5645 entry.file_name().to_str(),
5646 Some(
5647 "HEAD"
5648 | "ORIG_HEAD"
5649 | "COMMIT_EDITMSG"
5650 | "commondir"
5651 | "gitdir"
5652 | "index"
5653 | "logs"
5654 | "refs"
5655 )
5656 );
5657 if !known {
5658 return Ok(true);
5659 }
5660 }
5661
5662 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5663 if !commit_has_shared_ref(cwd, head.trim())? {
5664 return Ok(true);
5665 }
5666 Ok(false)
5667}
5668
5669fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
5670 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
5671 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
5672 bail!(
5673 "git returned a commit without a message separator in {}",
5674 cwd.display()
5675 );
5676 };
5677 Ok(commit[split + 2..].to_vec())
5678}
5679
5680fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
5681 let metadata = match std::fs::symlink_metadata(logs) {
5682 Ok(metadata) => metadata,
5683 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5684 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
5685 };
5686 if !metadata.is_dir() {
5687 return Ok(true);
5688 }
5689 let mut files = Vec::new();
5690 let mut directories = vec![logs.to_path_buf()];
5691 while let Some(directory) = directories.pop() {
5692 for entry in std::fs::read_dir(&directory)
5693 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
5694 {
5695 let entry =
5696 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5697 let path = entry.path();
5698 let kind = entry
5699 .file_type()
5700 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5701 if kind.is_dir() {
5702 directories.push(path);
5703 } else if kind.is_file() {
5704 files.push(path);
5705 } else {
5706 return Ok(true);
5707 }
5708 }
5709 }
5710
5711 let mut commits = BTreeSet::new();
5712 for path in files {
5713 if !collect_reflog_commits(cwd, &path, &mut commits)? {
5714 return Ok(true);
5715 }
5716 }
5717 for commit in commits {
5718 if !commit_has_shared_ref(cwd, &commit)? {
5719 return Ok(true);
5720 }
5721 }
5722 Ok(false)
5723}
5724
5725fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
5729 let common = common_git_dir(cwd)?;
5730 let reflog = common.join("logs").join(refname);
5731 let metadata = match std::fs::symlink_metadata(&reflog) {
5732 Ok(metadata) => metadata,
5733 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
5734 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
5735 };
5736 if !metadata.is_file() {
5737 return Ok(false);
5738 }
5739 let mut commits = BTreeSet::new();
5740 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
5741 return Ok(false);
5742 }
5743 for commit in commits {
5744 if is_ancestor(cwd, &commit, durable_tip)?
5745 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
5746 {
5747 continue;
5748 }
5749 return Ok(false);
5750 }
5751 Ok(true)
5752}
5753
5754fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
5755 let file = std::fs::File::open(path)
5756 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
5757 for line in std::io::BufReader::new(file).lines() {
5758 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
5759 let mut fields = line.splitn(3, ' ');
5760 let Some(old) = fields.next() else {
5761 return Ok(false);
5762 };
5763 let Some(new) = fields.next() else {
5764 return Ok(false);
5765 };
5766 if fields.next().is_none() {
5767 return Ok(false);
5768 }
5769 for oid in [old, new] {
5770 if oid.bytes().all(|byte| byte == b'0') {
5771 continue;
5772 }
5773 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
5774 return Ok(false);
5775 };
5776 commits.insert(commit);
5777 }
5778 }
5779 Ok(true)
5780}
5781
5782fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
5783 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
5784 let path = PathBuf::from(raw.trim());
5785 let path = if path.is_absolute() {
5786 path
5787 } else {
5788 cwd.join(path)
5789 };
5790 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
5791}
5792
5793fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
5794 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
5795 let output = proc::exec(
5796 &argv,
5797 &ExecOpts::new()
5798 .cwd(cwd)
5799 .timeout_secs(30)
5800 .check(false)
5801 .stop_descendants(true),
5802 )?;
5803 match output.code {
5804 0 => Ok(true),
5805 1 => Ok(false),
5806 _ => bail!("{}", proc::failure_message(&argv, &output)),
5807 }
5808}
5809
5810fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
5811 let commit = format!("{oid}^{{commit}}");
5812 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
5813 let output = proc::exec(
5814 &argv,
5815 &ExecOpts::new()
5816 .cwd(cwd)
5817 .timeout_secs(30)
5818 .check(false)
5819 .stop_descendants(true),
5820 )?;
5821 if output.code != 0 {
5822 return Ok(None);
5823 }
5824 let oid = output.stdout.trim();
5825 if oid.is_empty() {
5826 return Ok(None);
5827 }
5828 Ok(Some(oid.to_string()))
5829}
5830
5831fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
5832 commit_has_shared_ref_except(cwd, oid, None)
5833}
5834
5835fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
5836 let contains = format!("--contains={oid}");
5837 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
5838 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
5839 !record.is_empty()
5840 && !record.starts_with(b"refs/worktree/")
5841 && !record.starts_with(b"refs/bisect/")
5842 && !record.starts_with(b"refs/rewritten/")
5843 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
5844 }))
5845}
5846
5847fn repository_has_recoverable_work_inner(
5848 cwd: &Path,
5849 include_ignored: bool,
5850 visited: &mut BTreeSet<PathBuf>,
5851) -> Result<bool> {
5852 let canonical = std::fs::canonicalize(cwd)
5853 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5854 if !visited.insert(canonical.clone()) {
5855 bail!("submodule recursion revisited {}", canonical.display());
5856 }
5857 if include_ignored && !run_git_bytes(cwd, &["ls-files", "--others", "-z"])?.is_empty() {
5858 return Ok(true);
5859 }
5860 if !unsafe_index_flags(cwd)?.is_empty() {
5861 return Ok(true);
5862 }
5863 if attributes_may_be_modified(cwd)? {
5864 return Ok(true);
5865 }
5866 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
5867 return Ok(true);
5868 }
5869 if include_ignored {
5870 let index = index_entries(cwd)?
5871 .into_iter()
5872 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5873 .collect::<BTreeMap<_, _>>();
5874 let head = tree_entries(cwd, "HEAD")?
5875 .into_iter()
5876 .map(|entry| (entry.path, (entry.mode, entry.oid)))
5877 .collect::<BTreeMap<_, _>>();
5878 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
5879 return Ok(true);
5880 }
5881 let tracked = tracked_entries(cwd)?;
5882 let effective = check_attributes(cwd, tracked.keys().cloned())?;
5883 for (path, entry) in tracked {
5884 let Some(worktree) = entry.worktree else {
5885 return Ok(true);
5886 };
5887 let attributes = effective.get(&path).ok_or_else(|| {
5888 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
5889 })?;
5890 if path_has_ambiguous_transform(cwd, attributes)? {
5891 return Ok(true);
5892 }
5893 let symlink_file = entry.index_mode == "120000"
5894 && worktree.mode == "100644"
5895 && worktree.raw_oid == entry.index_oid
5896 && git_config_bool(cwd, "core.symlinks")? == Some(false);
5897 if worktree.mode != entry.index_mode && !symlink_file {
5898 return Ok(true);
5899 }
5900 if entry.index_mode == "120000" {
5901 if worktree.raw_oid != entry.index_oid {
5902 return Ok(true);
5903 }
5904 continue;
5905 }
5906 #[cfg(unix)]
5907 {
5908 let expected = if entry.index_mode == "100755" {
5909 0o755
5910 } else {
5911 0o644
5912 };
5913 if worktree.permissions != expected {
5914 return Ok(true);
5915 }
5916 }
5917 if allows_expected_crlf(cwd, attributes)? {
5918 let (normalized, every_lf_was_crlf) =
5919 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
5920 if !every_lf_was_crlf || normalized != entry.index_oid {
5921 return Ok(true);
5922 }
5923 } else if worktree.raw_oid != entry.index_oid {
5924 return Ok(true);
5925 }
5926 }
5927 } else {
5928 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
5929 if !run_git_bytes(cwd, &args)?.is_empty() {
5930 return Ok(true);
5931 }
5932 }
5933 for link in gitlinks(cwd)? {
5934 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
5935 continue;
5936 };
5937 if include_ignored {
5942 return Ok(true);
5943 }
5944 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5945 if head.trim() != link.oid {
5946 return Ok(true);
5947 }
5948 if repository_has_recoverable_work_inner(&submodule, include_ignored, visited)? {
5949 return Ok(true);
5950 }
5951 }
5952 Ok(false)
5953}
5954
5955pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
5956 repository_has_recoverable_work(cwd, false)
5957}
5958
5959fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
5960 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
5961 Ok(!run_git_bytes(cwd, &args)?.is_empty())
5962}
5963
5964#[cfg(unix)]
5965fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
5966 use std::os::unix::ffi::OsStringExt;
5967 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
5968}
5969
5970#[cfg(not(unix))]
5971fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
5972 String::from_utf8(raw.to_vec())
5973 .map(PathBuf::from)
5974 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
5975}
5976
5977fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
5978 let metadata = std::fs::symlink_metadata(path)
5979 .map_err(|e| spar_err!("could not inspect untracked file {}: {e}", path.display()))?;
5980 let kind = if metadata.file_type().is_symlink() {
5981 2
5982 } else if metadata.is_file() {
5983 1
5984 } else {
5985 bail!(
5986 "untracked path {} is not a regular file or symlink",
5987 path.display()
5988 );
5989 };
5990 let symlink_target = if kind == 2 {
5991 let target = std::fs::read_link(path)
5992 .map_err(|e| spar_err!("could not read untracked symlink {}: {e}", path.display()))?;
5993 Some(os_str_bytes(target.as_os_str())?)
5994 } else {
5995 None
5996 };
5997 #[cfg(unix)]
5998 {
5999 use std::os::unix::fs::MetadataExt;
6000 Ok(UntrackedFile {
6001 kind,
6002 len: metadata.len(),
6003 modified: metadata.modified().ok(),
6004 created: metadata.created().ok(),
6005 readonly: metadata.permissions().readonly(),
6006 symlink_target,
6007 device: metadata.dev(),
6008 inode: metadata.ino(),
6009 mode: metadata.mode(),
6010 change_seconds: metadata.ctime(),
6011 change_nanoseconds: metadata.ctime_nsec(),
6012 })
6013 }
6014 #[cfg(not(unix))]
6015 {
6016 Ok(UntrackedFile {
6017 kind,
6018 len: metadata.len(),
6019 modified: metadata.modified().ok(),
6020 created: metadata.created().ok(),
6021 readonly: metadata.permissions().readonly(),
6022 symlink_target,
6023 })
6024 }
6025}
6026
6027#[cfg(unix)]
6028fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6029 use std::os::unix::ffi::OsStrExt;
6030 Ok(value.as_bytes().to_vec())
6031}
6032
6033#[cfg(not(unix))]
6034fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6035 value
6036 .to_str()
6037 .map(|value| value.as_bytes().to_vec())
6038 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
6039}
6040
6041#[cfg(unix)]
6042fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6043 use std::os::unix::fs::MetadataExt;
6044 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
6045}
6046
6047#[cfg(not(unix))]
6048fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6049 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
6050}
6051
6052#[derive(Debug, Clone, serde::Serialize, Deserialize)]
6053pub struct BranchRecord {
6054 pub kind: String,
6055 pub number: i64,
6056}
6057
6058pub fn review_ref(number: i64) -> String {
6061 format!("refs/spar/pr-{number}")
6062}
6063
6064pub fn is_finished(state: &str) -> bool {
6065 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
6066}
6067
6068pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
6075 if let Some(parent) = path.parent() {
6076 std::fs::create_dir_all(parent)
6077 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6078 }
6079 let tmp = path.with_extension(format!(
6082 "{}.tmp",
6083 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
6084 ));
6085 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
6086 std::fs::rename(&tmp, path)
6087 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
6088 Ok(())
6089}
6090
6091pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
6094 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
6095}
6096
6097pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
6104 #[derive(Deserialize)]
6105 #[serde(rename_all = "camelCase")]
6106 struct Row {
6107 number: i64,
6108 #[serde(default)]
6109 url: String,
6110 #[serde(default)]
6111 title: String,
6112 #[serde(default)]
6113 closing_issues_references: Vec<IssueRef>,
6114 }
6115
6116 serde_json::from_str::<Vec<Row>>(json.trim())
6117 .ok()?
6118 .into_iter()
6119 .find(|row| {
6120 row.closing_issues_references
6121 .iter()
6122 .any(|linked| linked.number == issue)
6123 })
6124 .map(|row| PrRef {
6125 number: row.number,
6126 url: row.url,
6127 title: row.title,
6128 })
6129}
6130
6131fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
6138 if text.trim().is_empty() {
6139 return Err(spar_err!("GitHub returned no comment data"));
6140 }
6141 let mut out = Vec::new();
6142 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6143 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
6144 Value::Array(items) => out.extend(items),
6145 _ => return Err(spar_err!("unexpected non-array comment page")),
6146 }
6147 }
6148 Ok(out)
6149}
6150
6151pub fn parse_comment_pages(text: &str) -> Vec<Value> {
6152 let mut out = Vec::new();
6153 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6154 match value {
6155 Ok(Value::Array(items)) => out.extend(items),
6156 Ok(other) => out.push(other),
6157 Err(_) => break,
6158 }
6159 }
6160 out
6161}
6162
6163pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
6166 let marker = body.find(STATE_MARKER)?;
6167 let start = body[marker..].find('{')? + marker;
6168 let end = body.rfind('}')?;
6169 if end <= start {
6170 return None;
6171 }
6172 match serde_json::from_str(&body[start..=end]) {
6173 Ok(state) => Some(state),
6174 Err(_) => {
6175 logdim!("found a spar state comment but could not parse it");
6176 None
6177 }
6178 }
6179}
6180
6181fn choose_state_for_head(
6182 candidates: Vec<PersistedState>,
6183 actual_head: &str,
6184) -> Option<PersistedState> {
6185 let matching: Vec<PersistedState> = candidates
6186 .iter()
6187 .filter(|state| state.pr_head == actual_head)
6188 .cloned()
6189 .collect();
6190 if !matching.is_empty() {
6191 return newest_state(matching);
6192 }
6193 newest_state(candidates)
6194}
6195
6196fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
6197 candidates.into_iter().reduce(|best, candidate| {
6198 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
6199 candidate
6200 } else {
6201 best
6206 }
6207 })
6208}
6209
6210pub fn self_binary() -> Result<PathBuf> {
6216 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
6217 let path = PathBuf::from(path);
6218 if proc::is_executable(&path) {
6219 return Ok(path);
6220 }
6221 bail!(
6222 "SPAR_SELF_BIN is set to {}, which is not executable",
6223 path.display()
6224 );
6225 }
6226 std::env::current_exe()
6227 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
6228}
6229
6230fn bool_env(value: bool) -> &'static str {
6231 if value {
6232 "1"
6233 } else {
6234 "0"
6235 }
6236}
6237
6238pub fn sh_quote(text: &str) -> String {
6241 format!("'{}'", text.replace('\'', r"'\''"))
6242}
6243
6244pub fn style_from_env() -> Style {
6247 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
6248 Style {
6249 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
6250 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
6251 ..Style::permissive()
6252 }
6253}
6254
6255#[cfg(test)]
6256mod tests {
6257 use super::*;
6258 use crate::config::StateStore;
6259 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
6260 use std::process::Command;
6261
6262 fn repo_for_titles() -> Repo {
6263 Repo {
6264 root: PathBuf::from("/nonexistent"),
6265 style: Style::default(),
6266 branch_prefix: String::new(),
6267 state_store: StateStore::Local,
6268 followups: crate::config::Followups::Issues,
6269 drafts: Drafts::Never,
6270 viewer: OnceLock::new(),
6271 checkpoints: Mutex::new(BTreeMap::new()),
6272 writes: WriteStats::default(),
6273 }
6274 }
6275
6276 #[test]
6277 fn write_results_accumulate_for_the_run() {
6278 let repo = repo_for_titles();
6279
6280 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
6281 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
6282
6283 assert_eq!(
6284 WriteSummary {
6285 attempted: 2,
6286 failed: 1,
6287 },
6288 repo.write_summary()
6289 );
6290 }
6291
6292 #[test]
6293 fn only_failed_write_preflights_join_the_summary() {
6294 let repo = repo_for_titles();
6295
6296 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
6297 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
6298
6299 assert_eq!(
6300 WriteSummary {
6301 attempted: 1,
6302 failed: 1,
6303 },
6304 repo.write_summary()
6305 );
6306 }
6307
6308 #[test]
6309 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
6310 let repo = repo_for_titles();
6311
6312 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
6313 assert_eq!(
6314 WriteSummary {
6315 attempted: 1,
6316 failed: 1,
6317 },
6318 repo.write_summary()
6319 );
6320 }
6321
6322 #[test]
6323 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
6324 let mut repo = repo_for_titles();
6325 repo.followups = Followups::Local;
6326
6327 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
6328 assert_eq!(WriteSummary::default(), repo.write_summary());
6329 }
6330
6331 #[test]
6332 fn a_failed_remote_state_read_stops_before_state_mutation() {
6333 let root = std::env::temp_dir().join(format!(
6334 "spar-state-preflight-{}-{}",
6335 std::process::id(),
6336 std::time::SystemTime::now()
6337 .duration_since(std::time::UNIX_EPOCH)
6338 .unwrap()
6339 .as_nanos()
6340 ));
6341 std::fs::create_dir_all(&root).unwrap();
6342 let _fixture = ReviewFixture { root: root.clone() };
6343 let mut repo = repo_for_titles();
6344 repo.root = root;
6345 repo.state_store = StateStore::Both;
6346 let state = PersistedState {
6347 version: 1,
6348 checkpoint: 4,
6349 round: 2,
6350 next_actor: "a".into(),
6351 status: Status::Pending,
6352 pr_head: "abc123".into(),
6353 ledger: Ledger::new(),
6354 filed: Vec::new(),
6355 open_findings: Vec::new(),
6356 disputes: Vec::new(),
6357 noted: Vec::new(),
6358 };
6359
6360 let error = repo
6361 .write_state_after_remote_read(
6362 7,
6363 &state,
6364 Err(crate::error::SparError::new("state comments unavailable")),
6365 )
6366 .unwrap_err();
6367
6368 assert!(error.to_string().contains("state comments unavailable"));
6369 assert!(!repo.state_path(7).exists());
6370 assert_eq!(0, repo.remembered_checkpoint(7));
6371 assert_eq!(
6372 WriteSummary {
6373 attempted: 1,
6374 failed: 1,
6375 },
6376 repo.write_summary()
6377 );
6378 }
6379
6380 #[test]
6381 fn only_known_build_and_cache_directories_are_generated_artifacts() {
6382 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
6383 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
6384 assert!(is_generated_artifact(Path::new(
6385 "package/node_modules/dependency/file.js"
6386 )));
6387 assert!(!is_generated_artifact(Path::new(
6388 "distribution/required-package.js"
6389 )));
6390 assert!(!is_generated_artifact(Path::new(
6391 "generated/required-fixture.txt"
6392 )));
6393 assert!(!is_generated_artifact(Path::new("local.env")));
6394 }
6395
6396 struct ReviewFixture {
6397 root: PathBuf,
6398 }
6399
6400 impl Drop for ReviewFixture {
6401 fn drop(&mut self) {
6402 let _ = std::fs::remove_dir_all(&self.root);
6403 }
6404 }
6405
6406 fn test_git(cwd: &Path, args: &[&str]) -> String {
6407 let output = Command::new("git")
6408 .args(args)
6409 .current_dir(cwd)
6410 .output()
6411 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
6412 assert!(
6413 output.status.success(),
6414 "git {args:?} failed: {}",
6415 String::from_utf8_lossy(&output.stderr)
6416 );
6417 String::from_utf8_lossy(&output.stdout).into_owned()
6418 }
6419
6420 fn review_fixture(
6421 tag: &str,
6422 number: i64,
6423 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
6424 use std::sync::atomic::{AtomicU32, Ordering};
6425 static NEXT: AtomicU32 = AtomicU32::new(0);
6426 let id = NEXT.fetch_add(1, Ordering::Relaxed);
6427 let root =
6428 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
6429 let origin = root.join("origin.git");
6430 let work = root.join("work");
6431 std::fs::create_dir_all(&origin).unwrap();
6432 std::fs::create_dir_all(&work).unwrap();
6433 test_git(&origin, &["init", "--bare", "-b", "main"]);
6434 test_git(&work, &["init", "-b", "main"]);
6435 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
6436 test_git(&work, &["config", "user.name", "spar test"]);
6437 test_git(&work, &["config", "commit.gpgsign", "false"]);
6438 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
6439 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
6440 std::fs::write(work.join("README.md"), "seed\n").unwrap();
6441 std::fs::write(work.join("data.txt"), "old\n").unwrap();
6442 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
6443 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
6444 test_git(&work, &["add", "."]);
6445 test_git(&work, &["commit", "-m", "seed"]);
6446 test_git(
6447 &work,
6448 &["remote", "add", "origin", origin.to_str().unwrap()],
6449 );
6450 test_git(&work, &["push", "-u", "origin", "main"]);
6451 test_git(
6452 &work,
6453 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
6454 );
6455 let cfg = crate::config::parse(
6456 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
6457 )
6458 .unwrap();
6459 let repo = Repo::open(&work, &cfg).unwrap();
6460 let path = repo.worktree_for_pr_head(number).unwrap();
6461 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6462 (ReviewFixture { root }, repo, path, checkpoint)
6463 }
6464
6465 #[test]
6466 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
6467 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
6468
6469 repo.release_review_worktree_checked(901, &checkpoint)
6470 .unwrap();
6471
6472 assert!(!path.exists());
6473 }
6474
6475 #[test]
6476 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
6477 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
6478 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
6479 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
6480 test_git(&path, &["add", "recovery.txt"]);
6481 test_git(&path, &["commit", "-m", "recovery commit"]);
6482 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
6483 test_git(&path, &["reset", "--hard", "main"]);
6484
6485 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
6486 test_git(
6487 &path,
6488 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
6489 );
6490 }
6491
6492 #[test]
6493 fn a_review_ref_reflog_only_commit_prevents_deletion() {
6494 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
6495 let local_ref = review_ref(921);
6496 let original = test_git(&path, &["rev-parse", &local_ref]);
6497 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
6498 let recovery = test_git(
6499 &path,
6500 &[
6501 "commit-tree",
6502 tree.trim(),
6503 "-p",
6504 original.trim(),
6505 "-m",
6506 "review ref recovery",
6507 ],
6508 );
6509 test_git(
6510 &path,
6511 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
6512 );
6513 test_git(
6514 &path,
6515 &["update-ref", &local_ref, original.trim(), recovery.trim()],
6516 );
6517
6518 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
6519 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
6520 test_git(
6521 &path,
6522 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
6523 );
6524 }
6525
6526 #[test]
6527 fn an_unpublished_commit_message_draft_is_recoverable() {
6528 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
6529 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
6530 let git_dir = if raw.is_absolute() {
6531 raw
6532 } else {
6533 path.join(raw)
6534 };
6535 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
6536
6537 assert!(repository_has_recoverable_work(&path, true).unwrap());
6538 assert_eq!(
6539 "unique recovery draft\n",
6540 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
6541 );
6542 }
6543
6544 #[test]
6545 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
6546 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
6547 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
6548
6549 let error = repo
6550 .release_review_worktree_checked(902, &checkpoint)
6551 .unwrap_err();
6552
6553 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6554 assert!(error.to_string().contains("kept for recovery"), "{error}");
6555 assert_eq!(
6556 "recover me\n",
6557 std::fs::read_to_string(path.join("README.md")).unwrap()
6558 );
6559 repo.release_review_worktree(902);
6560 }
6561
6562 #[test]
6563 fn a_review_commit_is_retained_after_a_checked_read() {
6564 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
6565 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
6566 test_git(&path, &["add", "review-note.txt"]);
6567 test_git(&path, &["commit", "-m", "local review recovery"]);
6568 let head = test_git(&path, &["rev-parse", "HEAD"]);
6569
6570 let error = repo
6571 .release_review_worktree_checked(903, &checkpoint)
6572 .unwrap_err();
6573
6574 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6575 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
6576 assert_eq!(
6577 "recover me\n",
6578 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
6579 );
6580 repo.release_review_worktree(903);
6581 }
6582
6583 #[test]
6584 fn an_ignored_review_file_is_retained_after_a_checked_read() {
6585 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
6586 std::fs::create_dir_all(path.join("generated")).unwrap();
6587 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
6588
6589 let error = repo
6590 .release_review_worktree_checked(904, &checkpoint)
6591 .unwrap_err();
6592
6593 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6594 assert_eq!(
6595 "recover me\n",
6596 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
6597 );
6598 repo.release_review_worktree(904);
6599 }
6600
6601 #[test]
6602 fn a_preexisting_ignored_review_file_change_is_retained() {
6603 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
6604 std::fs::create_dir_all(path.join("generated")).unwrap();
6605 let ignored = path.join("generated/recovery.txt");
6606 std::fs::write(&ignored, "before\n").unwrap();
6607 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6608 std::fs::write(&ignored, "after!\n").unwrap();
6609
6610 let error = repo
6611 .release_review_worktree_checked(905, &checkpoint)
6612 .unwrap_err();
6613
6614 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6615 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
6616 repo.release_review_worktree(905);
6617 }
6618
6619 #[test]
6620 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
6621 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
6622 std::fs::create_dir_all(path.join("generated")).unwrap();
6623 let ignored = path.join("generated/recovery.txt");
6624 std::fs::write(&ignored, "keep me\n").unwrap();
6625 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6626
6627 let error = repo
6628 .release_review_worktree_checked(906, &checkpoint)
6629 .unwrap_err();
6630
6631 assert!(error.to_string().contains("recoverable"), "{error}");
6632 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
6633 }
6634
6635 #[test]
6636 fn overwriting_a_preexisting_untracked_file_is_detected() {
6637 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
6638 let untracked = path.join("notes.txt");
6639 std::fs::write(&untracked, "before\n").unwrap();
6640 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6641 std::fs::write(&untracked, "after!\n").unwrap();
6642
6643 let error = repo
6644 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6645 .unwrap_err();
6646
6647 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6648 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
6649 }
6650
6651 #[test]
6652 fn an_assume_unchanged_edit_is_detected() {
6653 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
6654 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
6655 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
6656
6657 let error = repo
6658 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6659 .unwrap_err();
6660
6661 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6662 assert_eq!(
6663 "hidden\n",
6664 std::fs::read_to_string(path.join("README.md")).unwrap()
6665 );
6666 }
6667
6668 #[test]
6669 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
6670 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
6671 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
6672 test_git(&path, &["add", "README.md"]);
6673 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
6674
6675 let error = repo
6676 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6677 .unwrap_err();
6678
6679 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6680 assert_eq!(
6681 b"seed\r\n",
6682 std::fs::read(path.join("README.md")).unwrap().as_slice()
6683 );
6684 }
6685
6686 #[cfg(unix)]
6687 #[test]
6688 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
6689 use std::os::unix::fs::PermissionsExt;
6690
6691 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
6692 test_git(&path, &["config", "core.filemode", "false"]);
6693 let readme = path.join("README.md");
6694 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
6695 permissions.set_mode(0o755);
6696 std::fs::set_permissions(&readme, permissions).unwrap();
6697 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
6698
6699 let error = repo
6700 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6701 .unwrap_err();
6702
6703 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6704 assert_eq!(
6705 0o755,
6706 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
6707 );
6708 }
6709
6710 #[test]
6711 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
6712 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
6713 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
6714 test_git(&path, &["add", ".gitattributes"]);
6715 test_git(&path, &["commit", "-m", "select data filter"]);
6716 let baseline = repo.worktree_baseline(&path).unwrap();
6717 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
6718
6719 assert!(repo
6720 .commit_pending_changes(&path, &baseline, "change data", "change data")
6721 .unwrap());
6722 let error = repo
6723 .refuse_unrepresented_tracked_changes(&path, &baseline)
6724 .unwrap_err();
6725
6726 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6727 assert_eq!(
6728 "secret: recover me\nnew\n",
6729 std::fs::read_to_string(path.join("data.txt")).unwrap()
6730 );
6731 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
6732 }
6733
6734 #[test]
6735 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
6736 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
6737 std::fs::create_dir_all(path.join("target")).unwrap();
6738 let untracked = path.join("target/user.yaml");
6739 std::fs::write(&untracked, "user data\n").unwrap();
6740 let baseline = repo.worktree_baseline(&path).unwrap();
6741 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
6742
6743 assert!(repo
6744 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
6745 .unwrap());
6746
6747 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
6748 assert_eq!(
6749 "?? target/user.yaml\n",
6750 test_git(&path, &["status", "--short", "--untracked-files=all"])
6751 );
6752 assert!(test_git(
6753 &path,
6754 &[
6755 "ls-tree",
6756 "-r",
6757 "--name-only",
6758 "HEAD",
6759 "--",
6760 "target/user.yaml"
6761 ]
6762 )
6763 .is_empty());
6764 }
6765
6766 #[test]
6767 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
6768 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
6769 std::fs::create_dir_all(path.join("target")).unwrap();
6770 let untracked = path.join("target/user.yaml");
6771 std::fs::write(&untracked, "before\n").unwrap();
6772 let baseline = repo.worktree_baseline(&path).unwrap();
6773 let before = test_git(&path, &["rev-parse", "HEAD"]);
6774 std::fs::write(&untracked, "after\n").unwrap();
6775 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
6776
6777 let error = repo
6778 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
6779 .unwrap_err();
6780
6781 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6782 assert!(error.to_string().contains("target/user.yaml"), "{error}");
6783 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
6784 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
6785 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
6786 }
6787
6788 #[test]
6789 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
6790 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
6791 let baseline = repo.worktree_baseline(&path).unwrap();
6792 std::fs::create_dir_all(path.join("target")).unwrap();
6793 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
6794
6795 assert!(repo
6796 .commit_pending_changes(&path, &baseline, "add file", "add file")
6797 .unwrap());
6798
6799 assert_eq!(
6800 "new file\n",
6801 test_git(&path, &["show", "HEAD:target/new.txt"])
6802 );
6803 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
6804 }
6805
6806 #[test]
6807 fn deleting_existing_ignored_work_stops_a_managed_commit() {
6808 let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
6809 std::fs::create_dir_all(path.join("generated")).unwrap();
6810 let ignored = path.join("generated/keep.txt");
6811 std::fs::write(&ignored, "user data\n").unwrap();
6812 let baseline = repo.worktree_baseline(&path).unwrap();
6813 let before = test_git(&path, &["rev-parse", "HEAD"]);
6814 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
6815 std::fs::remove_file(&ignored).unwrap();
6816
6817 let error = repo
6818 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
6819 .unwrap_err();
6820
6821 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6822 assert!(error.to_string().contains("existing untracked"), "{error}");
6823 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
6824 assert_eq!(
6825 "tracked change\n",
6826 std::fs::read_to_string(path.join("README.md")).unwrap()
6827 );
6828 }
6829
6830 #[test]
6831 fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
6832 let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
6833 let baseline = repo.worktree_baseline(&path).unwrap();
6834 let before = test_git(&path, &["rev-parse", "HEAD"]);
6835 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
6836 std::fs::create_dir_all(path.join("generated")).unwrap();
6837 let ignored = path.join("generated/recovery.txt");
6838 std::fs::write(&ignored, "keep me\n").unwrap();
6839
6840 let error = repo
6841 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
6842 .unwrap_err();
6843
6844 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6845 assert!(error.to_string().contains("recovery.txt"), "{error}");
6846 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
6847 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
6848 assert!(test_git(&path, &["status", "--porcelain"])
6849 .lines()
6850 .any(|line| line == "M README.md"));
6851 }
6852
6853 #[test]
6854 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
6855 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
6856 test_git(&path, &["config", "core.autocrlf", "true"]);
6857 std::fs::write(path.join("README.md"), "seed\n").unwrap();
6858 assert_eq!(
6859 test_git(&path, &["hash-object", "README.md"]).trim(),
6860 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
6861 );
6862
6863 assert!(repository_has_recoverable_work(&path, true).unwrap());
6864 assert_eq!(
6865 "seed\n",
6866 std::fs::read_to_string(path.join("README.md")).unwrap()
6867 );
6868 }
6869
6870 #[test]
6871 fn autocrlf_input_overrides_a_crlf_core_eol() {
6872 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
6873 test_git(&path, &["config", "core.autocrlf", "input"]);
6874 test_git(&path, &["config", "core.eol", "crlf"]);
6875 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
6876 assert_eq!(
6877 test_git(&path, &["hash-object", "README.md"]).trim(),
6878 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
6879 );
6880
6881 assert!(repository_has_recoverable_work(&path, true).unwrap());
6882 assert_eq!(
6883 b"seed\r\n",
6884 std::fs::read(path.join("README.md")).unwrap().as_slice()
6885 );
6886 }
6887
6888 #[cfg(unix)]
6889 #[test]
6890 fn a_non_executable_permission_change_is_recoverable() {
6891 use std::os::unix::fs::PermissionsExt;
6892
6893 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
6894 let readme = path.join("README.md");
6895 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
6896 permissions.set_mode(0o600);
6897 std::fs::set_permissions(&readme, permissions).unwrap();
6898 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
6899
6900 let error = repo
6901 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6902 .unwrap_err();
6903
6904 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6905 assert!(repository_has_recoverable_work(&path, true).unwrap());
6906 assert_eq!(
6907 0o600,
6908 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
6909 );
6910 }
6911
6912 #[cfg(unix)]
6913 #[test]
6914 fn a_managed_commit_skips_signing_and_hooks() {
6915 use std::os::unix::fs::PermissionsExt;
6916
6917 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
6918 let common = common_git_dir(&path).unwrap();
6919 let hook = common.join("hooks/pre-commit");
6920 let marker = fixture.root.join("hook-ran");
6921 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
6922 std::fs::write(
6923 &hook,
6924 format!(
6925 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
6926 sh_quote(marker.to_str().unwrap())
6927 ),
6928 )
6929 .unwrap();
6930 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
6931 permissions.set_mode(0o755);
6932 std::fs::set_permissions(&hook, permissions).unwrap();
6933 test_git(&path, &["config", "commit.gpgsign", "true"]);
6934 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
6935 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
6936 test_git(&path, &["add", "managed.txt"]);
6937
6938 repo.commit_staged_changes(&path, "record managed change")
6939 .unwrap();
6940
6941 assert!(!marker.exists());
6942 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
6943 }
6944
6945 #[test]
6946 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
6947 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
6948 std::fs::write(
6949 path.join(".gitattributes"),
6950 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
6951 )
6952 .unwrap();
6953 test_git(&path, &["add", ".gitattributes"]);
6954 test_git(&path, &["commit", "-m", "select automatic text"]);
6955 test_git(&path, &["config", "core.autocrlf", "true"]);
6956
6957 assert!(repository_has_recoverable_work(&path, true).unwrap());
6958 }
6959
6960 #[test]
6961 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
6962 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
6963 std::fs::write(
6964 path.join(".gitattributes"),
6965 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
6966 )
6967 .unwrap();
6968 test_git(&path, &["add", ".gitattributes"]);
6969 test_git(&path, &["commit", "-m", "select ident expansion"]);
6970 std::fs::write(path.join("README.md"), "seed\n").unwrap();
6971
6972 assert!(repository_has_recoverable_work(&path, true).unwrap());
6973 }
6974
6975 #[test]
6976 fn a_legacy_crlf_checkout_is_retained_conservatively() {
6977 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
6978 std::fs::write(
6979 path.join(".gitattributes"),
6980 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
6981 )
6982 .unwrap();
6983 test_git(&path, &["add", ".gitattributes"]);
6984 test_git(&path, &["commit", "-m", "select legacy line endings"]);
6985
6986 assert!(repository_has_recoverable_work(&path, true).unwrap());
6987 }
6988
6989 #[test]
6990 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
6991 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
6992 let nested = path.join("tracked");
6993 std::fs::create_dir_all(&nested).unwrap();
6994 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
6995 test_git(&path, &["add", "tracked/seed.txt"]);
6996 test_git(&path, &["commit", "-m", "add tracked directory"]);
6997 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6998 test_git(&nested, &["init"]);
6999
7000 let error = repo
7001 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7002 .unwrap_err();
7003
7004 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7005 assert!(error.to_string().contains("Git entry"), "{error}");
7006 assert!(nested.join(".git").exists());
7007 }
7008
7009 #[cfg(unix)]
7010 #[test]
7011 fn a_non_utf8_git_path_is_preserved_without_loss() {
7012 use std::os::unix::ffi::OsStrExt;
7013
7014 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
7015
7016 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
7017 }
7018
7019 #[test]
7020 fn guarded_merge_pins_the_reviewed_head() {
7021 let args = merge_pr_args("36", Some("abc123"), true);
7022 assert_eq!(
7023 vec![
7024 "pr",
7025 "merge",
7026 "36",
7027 "--squash",
7028 "--delete-branch",
7029 "--match-head-commit",
7030 "abc123"
7031 ],
7032 args
7033 );
7034 }
7035
7036 #[test]
7037 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
7038 let pr = PrRef {
7039 number: 7,
7040 url: "https://example.test/pull/7".into(),
7041 title: "part one".into(),
7042 };
7043 let result = reconcile_pr_creation(
7044 "split-34-1",
7045 Err(crate::error::SparError::new("connection lost")),
7046 Ok(Some(pr)),
7047 )
7048 .unwrap();
7049 assert_eq!(7, result.number);
7050 }
7051
7052 #[test]
7053 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
7054 let error = reconcile_pr_creation(
7055 "split-34-1",
7056 Err(crate::error::SparError::new("permission denied")),
7057 Ok(None),
7058 )
7059 .unwrap_err();
7060 assert!(error.to_string().contains("permission denied"), "{error}");
7061 }
7062
7063 #[test]
7064 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
7065 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
7066 assert!(pr_for_base(text, "split-34-2", "split-34-1")
7067 .unwrap()
7068 .is_none());
7069 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
7070 assert_eq!(7, found.number);
7071 }
7072
7073 #[test]
7074 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
7075 let result = reconcile_comment_post(
7076 34,
7077 "the summary",
7078 crate::error::SparError::new("connection lost"),
7079 Ok(vec![serde_json::json!({"body": "the summary"})]),
7080 );
7081 assert!(result.is_ok(), "{result:?}");
7082 }
7083
7084 #[test]
7085 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
7086 let error = reconcile_comment_post(
7087 34,
7088 "the summary",
7089 crate::error::SparError::new("connection lost"),
7090 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
7091 )
7092 .unwrap_err();
7093 assert_eq!("connection lost", error.to_string());
7094 }
7095
7096 #[test]
7097 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
7098 let error = reconcile_comment_post(
7099 34,
7100 "the summary",
7101 crate::error::SparError::new("connection lost"),
7102 Err(crate::error::SparError::new("comments unavailable")),
7103 )
7104 .unwrap_err();
7105 assert!(
7106 error.to_string().contains("could not be verified"),
7107 "{error}"
7108 );
7109 assert!(
7110 error.to_string().contains("comments unavailable"),
7111 "{error}"
7112 );
7113 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7114 assert!(!error.worth_retrying());
7115 }
7116
7117 #[test]
7118 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
7119 let result = reconcile_issue_edit(
7120 34,
7121 "wanted body",
7122 crate::error::SparError::new("connection lost"),
7123 Ok("wanted body".to_string()),
7124 );
7125 assert!(result.is_ok(), "{result:?}");
7126 }
7127
7128 #[test]
7129 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
7130 let error = reconcile_issue_edit(
7131 34,
7132 "wanted body",
7133 crate::error::SparError::new("connection lost"),
7134 Err(crate::error::SparError::new("issue unavailable")),
7135 )
7136 .unwrap_err();
7137 assert!(
7138 error.to_string().contains("could not be verified"),
7139 "{error}"
7140 );
7141 assert!(error.to_string().contains("issue unavailable"), "{error}");
7142 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7143 assert!(!error.worth_retrying());
7144 }
7145
7146 #[test]
7147 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
7148 let found = ExistingIssue {
7149 number: 101,
7150 url: "https://example.test/issues/101".into(),
7151 title: "child".into(),
7152 body: "body".into(),
7153 open: true,
7154 };
7155 let url = reconcile_issue_creation(
7156 "child",
7157 Err(crate::error::SparError::new("connection lost")),
7158 Ok(Some(found)),
7159 )
7160 .unwrap();
7161 assert_eq!("https://example.test/issues/101", url);
7162 }
7163
7164 #[test]
7165 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
7166 let error = reconcile_issue_creation(
7167 "child",
7168 Err(crate::error::SparError::new("permission denied")),
7169 Ok(None),
7170 )
7171 .unwrap_err();
7172 assert!(error.to_string().contains("permission denied"), "{error}");
7173 }
7174
7175 #[test]
7176 fn an_unverifiable_issue_creation_is_marked_uncertain() {
7177 let error = reconcile_issue_creation(
7178 "child",
7179 Err(crate::error::SparError::new("connection lost")),
7180 Err(crate::error::SparError::new("issues unavailable")),
7181 )
7182 .unwrap_err();
7183 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7184 assert!(!error.worth_retrying());
7185 }
7186
7187 #[test]
7188 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
7189 let result = reconcile_failed_split_push(
7190 "split-34-1",
7191 crate::error::SparError::new("connection lost"),
7192 Ok("abc123\n".into()),
7193 Ok("abc123\trefs/heads/split-34-1\n".into()),
7194 );
7195 assert!(result.is_ok(), "{result:?}");
7196 }
7197
7198 #[test]
7199 fn a_split_push_collision_is_definite_and_never_overwrites() {
7200 let error = reconcile_failed_split_push(
7201 "split-34-1",
7202 crate::error::SparError::new("lease rejected"),
7203 Ok("abc123\n".into()),
7204 Ok("def456\trefs/heads/split-34-1\n".into()),
7205 )
7206 .unwrap_err();
7207 assert!(!error.retain_worktree());
7208 assert!(
7209 error.to_string().contains("Nothing was overwritten"),
7210 "{error}"
7211 );
7212 }
7213
7214 #[test]
7215 fn an_unreadable_split_push_result_keeps_the_worktree() {
7216 let error = reconcile_failed_split_push(
7217 "split-34-1",
7218 crate::error::SparError::new("connection lost"),
7219 Ok("abc123\n".into()),
7220 Err(crate::error::SparError::new("origin unavailable")),
7221 )
7222 .unwrap_err();
7223 assert!(error.retain_worktree());
7224 assert!(error.to_string().contains("could not confirm"), "{error}");
7225 }
7226
7227 #[test]
7231 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
7232 let repo = repo_for_titles();
7233 for raw in [
7234 "Retry loop spins \u{2014} Retry-After parses to zero",
7235 "plain title",
7236 " spread over\nlines ",
7237 "\u{1F916} Generated with something",
7238 &format!("a \u{2014} {}", "very long title ".repeat(20)),
7239 &"x".repeat(300),
7240 &format!("{} \u{2014} end", "y".repeat(88)),
7241 &{
7246 let tail = "a\u{2014}b c\u{2014}d";
7247 let pad = Style::default().max_title_chars - tail.chars().count();
7248 format!("{}{tail}", "w".repeat(pad))
7249 },
7250 ] {
7251 let once = repo.clean_title(raw).unwrap();
7252 let twice = repo.clean_title(&once).unwrap();
7253 assert_eq!(once, twice, "not idempotent for {raw:?}");
7254 assert!(
7255 once.chars().count() <= repo.style.max_title_chars,
7256 "over budget: {once:?}"
7257 );
7258 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
7259 }
7260 }
7261
7262 #[test]
7263 fn a_title_with_an_em_dash_survives_as_readable_text() {
7264 let repo = repo_for_titles();
7265 assert_eq!(
7266 "Retry loop spins, Retry-After parses to zero",
7267 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
7268 .unwrap()
7269 );
7270 }
7271
7272 #[test]
7273 fn sh_quote_survives_a_quote() {
7274 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
7275 }
7276
7277 #[test]
7278 fn sh_quote_wraps_a_space() {
7279 assert_eq!(
7280 "'/Applications/My App/spar'",
7281 sh_quote("/Applications/My App/spar")
7282 );
7283 }
7284
7285 #[test]
7286 fn finished_states_are_recognised_case_insensitively() {
7287 assert!(is_finished("MERGED"));
7288 assert!(is_finished("closed"));
7289 assert!(!is_finished("OPEN"));
7290 assert!(!is_finished(""));
7291 }
7292
7293 fn state() -> PersistedState {
7294 PersistedState {
7295 version: 1,
7296 checkpoint: 0,
7297 round: 4,
7298 next_actor: "codex".into(),
7299 status: Status::Pending,
7300 pr_head: "abc123".into(),
7301 ledger: Ledger::new(),
7302 filed: vec![],
7303 open_findings: vec![Finding {
7304 severity: Severity::Blocking,
7305 title: "Unchecked error".into(),
7306 detail: "the failure is discarded".into(),
7307 file: "src/a.rs:12".into(),
7308 ..Finding::default()
7309 }],
7310 disputes: vec![Dispute {
7311 title: "Retry limit".into(),
7312 file: "src/net.rs".into(),
7313 reasoning: "the caller already bounds it".into(),
7314 }],
7315 noted: vec![Finding {
7316 severity: Severity::NonBlocking,
7317 title: "Timeout is fixed".into(),
7318 file: "src/config.rs".into(),
7319 ..Finding::default()
7320 }],
7321 }
7322 }
7323
7324 #[test]
7325 fn a_state_comment_round_trips() {
7326 let body = format!(
7327 "{STATE_MARKER}\n{}\n-->",
7328 serde_json::to_string(&state()).unwrap()
7329 );
7330 let back = parse_state_comment(&body).unwrap();
7331 assert_eq!(4, back.round);
7332 assert_eq!("codex", back.next_actor);
7333 assert_eq!("abc123", back.pr_head);
7334 assert_eq!("Unchecked error", back.open_findings[0].title);
7335 assert_eq!("src/net.rs", back.disputes[0].file);
7336 assert_eq!("Timeout is fixed", back.noted[0].title);
7337 }
7338
7339 #[test]
7340 fn old_state_without_new_lists_still_parses() {
7341 let body = format!(
7342 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
7343 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
7344 );
7345 let back = parse_state_comment(&body).expect("old state");
7346 assert!(back.open_findings.is_empty());
7347 assert!(back.disputes.is_empty());
7348 assert!(back.noted.is_empty());
7349 assert!(back.pr_head.is_empty());
7350 assert_eq!(0, back.checkpoint);
7351 }
7352
7353 #[test]
7354 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
7355 let mut local = state();
7356 local.pr_head = "old".into();
7357 local.round = 9;
7358 let mut remote = state();
7359 remote.pr_head = "current".into();
7360 remote.round = 4;
7361
7362 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
7363 assert_eq!("current", chosen.pr_head);
7364 assert_eq!(4, chosen.round);
7365 }
7366
7367 #[test]
7368 fn checkpoint_order_breaks_same_round_ties() {
7369 let mut local = state();
7370 local.pr_head = "current".into();
7371 local.round = 4;
7372 local.checkpoint = 8;
7373 let mut remote = local.clone();
7374 remote.checkpoint = 7;
7375 remote.open_findings.clear();
7376
7377 let chosen = choose_state_for_head(vec![local], "current").unwrap();
7378 assert_eq!(8, chosen.checkpoint);
7379
7380 let mut local = state();
7381 local.pr_head = "current".into();
7382 local.round = 4;
7383 local.checkpoint = 8;
7384 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
7385 assert_eq!(8, chosen.checkpoint);
7386 }
7387
7388 #[test]
7389 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
7390 let mut local = state();
7391 local.pr_head = "current".into();
7392 local.round = 4;
7393 local.open_findings.push(Finding {
7394 title: "local checkpoint".into(),
7395 ..Finding::default()
7396 });
7397 let mut remote = state();
7398 remote.pr_head = "current".into();
7399 remote.round = 4;
7400
7401 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
7402 assert_eq!(
7403 "local checkpoint",
7404 chosen.open_findings.last().unwrap().title
7405 );
7406 }
7407
7408 #[test]
7410 fn the_state_block_is_an_html_comment() {
7411 let body = format!(
7412 "{STATE_MARKER}\n{}\n-->",
7413 serde_json::to_string(&state()).unwrap()
7414 );
7415 assert!(body.starts_with("<!--"));
7416 assert!(body.trim_end().ends_with("-->"));
7417 assert!(!body[..body.find('{').unwrap()].contains("-->"));
7418 }
7419
7420 #[test]
7421 fn an_unrelated_json_block_is_not_state() {
7422 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
7423 }
7424
7425 #[test]
7426 fn a_malformed_state_comment_is_none_not_a_panic() {
7427 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
7428 }
7429
7430 #[test]
7431 fn atomic_write_leaves_no_temp_file() {
7432 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
7433 let _ = std::fs::remove_dir_all(&dir);
7434 let path = dir.join("state").join("pr-7.json");
7435 write_json_atomic(&path, &state()).unwrap();
7436 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
7437 .unwrap()
7438 .flatten()
7439 .filter_map(|e| e.file_name().to_str().map(str::to_string))
7440 .collect();
7441 assert_eq!(vec!["pr-7.json".to_string()], files);
7442 let _ = std::fs::remove_dir_all(&dir);
7443 }
7444
7445 #[test]
7446 fn atomic_write_overwrites_rather_than_accumulating() {
7447 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
7448 let _ = std::fs::remove_dir_all(&dir);
7449 let path = dir.join("pr-7.json");
7450 for round in 1..4 {
7451 let mut s = state();
7452 s.round = round;
7453 write_json_atomic(&path, &s).unwrap();
7454 }
7455 let back: PersistedState =
7456 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
7457 assert_eq!(3, back.round);
7458 let _ = std::fs::remove_dir_all(&dir);
7459 }
7460
7461 #[test]
7462 fn style_from_env_defaults_to_enforcing() {
7463 std::env::remove_var("SPAR_BAN_EM_DASH");
7464 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
7465 let style = style_from_env();
7466 assert!(style.ban_em_dash && style.ban_ai_attribution);
7467 assert!(
7468 !style.terse,
7469 "the commit filter must not truncate a commit message"
7470 );
7471 }
7472}
7473
7474#[cfg(test)]
7475mod comment_page_tests {
7476 use super::*;
7477
7478 #[test]
7479 fn a_single_merged_array_is_read() {
7480 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
7481 assert_eq!(2, pages.len());
7482 assert_eq!(Some(2), pages[1]["id"].as_i64());
7483 }
7484
7485 #[test]
7486 fn concatenated_pages_from_an_older_gh_are_read_too() {
7487 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
7488 assert_eq!(2, pages.len());
7489 }
7490
7491 #[test]
7495 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
7496 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
7497 let pages = parse_comment_pages(text);
7498 assert_eq!(2, pages.len(), "{pages:?}");
7499 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
7500 }
7501
7502 #[test]
7503 fn empty_output_is_no_comments_not_a_panic() {
7504 assert!(parse_comment_pages("").is_empty());
7505 assert!(parse_comment_pages(" ").is_empty());
7506 assert!(parse_comment_pages("[]").is_empty());
7507 }
7508
7509 #[test]
7510 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
7511 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
7512 }
7513
7514 #[test]
7515 fn a_write_postcheck_rejects_truncated_comment_pages() {
7516 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
7517 assert!(
7518 error.to_string().contains("unexpected comment pages"),
7519 "{error}"
7520 );
7521 }
7522
7523 #[test]
7524 fn a_write_postcheck_rejects_empty_or_non_array_output() {
7525 assert!(try_parse_comment_pages("").is_err());
7526 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
7527 assert!(try_parse_comment_pages("[]").is_ok());
7528 }
7529
7530 #[test]
7531 fn state_is_found_in_the_last_matching_comment() {
7532 let payload = |round: u32| {
7533 format!(
7534 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
7535 )
7536 };
7537 let text = serde_json::to_string(&serde_json::json!([
7538 {"id": 1, "body": payload(1)},
7539 {"id": 2, "body": "looks good to me"},
7540 {"id": 3, "body": payload(5)},
7541 ]))
7542 .unwrap();
7543 let pages = parse_comment_pages(&text);
7544 let last = pages
7545 .iter()
7546 .rev()
7547 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
7548 .unwrap();
7549 assert_eq!(5, last.round);
7550 }
7551}
7552
7553#[cfg(test)]
7554mod linked_pr_tests {
7555 use super::*;
7556
7557 const REAL_PAYLOAD: &str = r#"[
7562 {"number":14252,"title":"fix: reject leading-dash branch names",
7563 "url":"https://github.com/cli/cli/pull/14252",
7564 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
7565 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
7566 "url":"https://github.com/cli/cli/issues/14238"}]},
7567 {"number":14217,"title":"another change",
7568 "url":"https://github.com/cli/cli/pull/14217",
7569 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
7570 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
7571 "url":"https://github.com/cli/cli/issues/9761"}]},
7572 {"number":14200,"title":"unlinked work",
7573 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
7574 ]"#;
7575
7576 #[test]
7577 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
7578 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
7579 assert_eq!(14252, pr.number);
7580 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
7581 }
7582
7583 #[test]
7584 fn the_right_pr_is_picked_out_of_several() {
7585 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
7586 }
7587
7588 #[test]
7589 fn an_issue_nobody_is_working_on_finds_nothing() {
7590 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
7591 }
7592
7593 #[test]
7594 fn an_unlinked_pr_is_never_matched() {
7595 for issue in [14200, 0, 1] {
7597 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
7598 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
7599 }
7600 }
7601 }
7602
7603 #[test]
7604 fn empty_or_broken_output_is_none_rather_than_a_panic() {
7605 assert!(find_linked_pr("", 1).is_none());
7606 assert!(find_linked_pr("[]", 1).is_none());
7607 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
7608 assert!(find_linked_pr("[{\"number\":", 1).is_none());
7609 }
7610
7611 #[test]
7613 fn pr_view_reads_the_cross_repository_flag() {
7614 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
7615 "baseRefName":"main","state":"OPEN",
7616 "closingIssuesReferences":[],"isCrossRepository":true}"#;
7617 let pr: PrView = serde_json::from_str(json).unwrap();
7618 assert!(pr.is_cross_repository);
7619 assert!(pr.is_open());
7620
7621 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
7622 assert!(
7623 !serde_json::from_str::<PrView>(&same_repo)
7624 .unwrap()
7625 .is_cross_repository
7626 );
7627 }
7628}
7629
7630#[cfg(test)]
7631mod min_number_tests {
7632 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
7638 let mut numbers: Vec<i64> = open.to_vec();
7639 numbers.sort_unstable();
7640 if min_number > 0 {
7641 numbers.retain(|n| *n >= min_number);
7642 }
7643 numbers.truncate(limit);
7644 numbers
7645 }
7646
7647 #[test]
7648 fn the_floor_is_applied_before_the_cap_not_after() {
7649 let open = [12, 13, 14, 480, 481, 482];
7650 assert_eq!(vec![480, 481], pick(&open, 2, 480));
7651 assert!(!pick(&open, 2, 480).is_empty());
7654 }
7655
7656 #[test]
7657 fn no_floor_keeps_the_old_behaviour() {
7658 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
7659 }
7660
7661 #[test]
7662 fn the_floor_is_inclusive() {
7663 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
7664 }
7665
7666 #[test]
7667 fn a_floor_above_everything_open_yields_nothing() {
7668 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
7669 }
7670}