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