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(
2181 &self,
2182 cwd: &Path,
2183 baseline: &WorktreeBaseline,
2184 ) -> Result<()> {
2185 self.check_new_ignored_files(cwd, baseline, false).map(drop)
2186 }
2187
2188 fn allow_generated_ignored_files(
2191 &self,
2192 cwd: &Path,
2193 baseline: &WorktreeBaseline,
2194 ) -> Result<Vec<PathBuf>> {
2195 self.check_new_ignored_files(cwd, baseline, true)
2196 }
2197
2198 fn check_new_ignored_files(
2199 &self,
2200 cwd: &Path,
2201 baseline: &WorktreeBaseline,
2202 allow_generated: bool,
2203 ) -> Result<Vec<PathBuf>> {
2204 self.refuse_changed_attributes(cwd, baseline)?;
2205 let after = ignored_untracked_state(cwd).map_err(|e| {
2206 uncertain_worktree_change(
2207 cwd,
2208 format!(
2209 "could not verify untracked files in {} after editing: {}. The worktree was \
2210 kept for recovery.",
2211 cwd.display(),
2212 e.last_line()
2213 ),
2214 )
2215 })?;
2216 let changed = baseline.ignored_untracked.changed_paths(&after);
2217 if changed.is_empty() {
2218 return Ok(Vec::new());
2219 }
2220 let (generated, changed): (Vec<_>, Vec<_>) = changed.into_iter().partition(|path| {
2221 allow_generated && after.is_ignored(path) && is_generated_artifact(path)
2222 });
2223 if changed.is_empty() {
2224 return Ok(generated);
2225 }
2226 let mut listed = changed
2227 .iter()
2228 .take(5)
2229 .map(|path| format!("{:?}", path.as_os_str()))
2230 .collect::<Vec<_>>()
2231 .join(", ");
2232 if changed.len() > 5 {
2233 listed.push_str(&format!(", and {} more", changed.len() - 5));
2234 }
2235 Err(uncertain_worktree_change(
2236 cwd,
2237 format!(
2238 "the editing call created or changed untracked or ignored file(s) in {} that \
2239 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2240 recovery.",
2241 cwd.display()
2242 ),
2243 ))
2244 }
2245
2246 pub(crate) fn refuse_changed_existing_untracked(
2251 &self,
2252 cwd: &Path,
2253 baseline: &WorktreeBaseline,
2254 ) -> Result<()> {
2255 self.check_changed_existing_untracked(cwd, baseline, false)
2256 .map(drop)
2257 }
2258
2259 fn allow_changed_generated_artifacts(
2260 &self,
2261 cwd: &Path,
2262 baseline: &WorktreeBaseline,
2263 ) -> Result<Vec<PathBuf>> {
2264 self.check_changed_existing_untracked(cwd, baseline, true)
2265 }
2266
2267 fn check_changed_existing_untracked(
2268 &self,
2269 cwd: &Path,
2270 baseline: &WorktreeBaseline,
2271 allow_generated: bool,
2272 ) -> Result<Vec<PathBuf>> {
2273 self.refuse_changed_attributes(cwd, baseline)?;
2274 let after = ignored_untracked_state(cwd).map_err(|e| {
2275 uncertain_worktree_change(
2276 cwd,
2277 format!(
2278 "could not verify existing untracked files in {} after editing: {}. The \
2279 worktree was kept for recovery.",
2280 cwd.display(),
2281 e.last_line()
2282 ),
2283 )
2284 })?;
2285 let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2286 if changed.is_empty() {
2287 return Ok(Vec::new());
2288 }
2289 let (generated, changed): (Vec<_>, Vec<_>) = changed.into_iter().partition(|path| {
2290 allow_generated
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 || verified_untracked != before_filter_untracked {
2457 return Err(uncertain_worktree_change(
2458 cwd,
2459 "a content filter changed the worktree while SPAR verified the managed \
2460 commit. The worktree was kept for recovery.",
2461 ));
2462 }
2463 self.refuse_changed_existing_untracked(cwd, baseline)?;
2464 }
2465 if let Some(problem) = filter_problem {
2466 return Err(uncertain_worktree_change(
2467 cwd,
2468 format!("{problem}. The worktree was kept for recovery."),
2469 ));
2470 }
2471 if changed.is_empty() {
2472 return Ok(());
2473 }
2474 let mut listed = changed
2475 .iter()
2476 .take(5)
2477 .map(|path| format!("{:?}", path.as_os_str()))
2478 .collect::<Vec<_>>()
2479 .join(", ");
2480 if changed.len() > 5 {
2481 listed.push_str(&format!(", and {} more", changed.len() - 5));
2482 }
2483 Err(uncertain_worktree_change(
2484 cwd,
2485 format!(
2486 "the editing call changed tracked working-file bytes, modes, repositories, or \
2487 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
2488 recovery."
2489 ),
2490 ))
2491 }
2492
2493 pub(crate) fn refuse_changed_attributes(
2494 &self,
2495 cwd: &Path,
2496 baseline: &WorktreeBaseline,
2497 ) -> Result<()> {
2498 let after = attribute_state(cwd).map_err(|e| {
2499 uncertain_worktree_change(
2500 cwd,
2501 format!(
2502 "could not verify attribute files in {} after editing: {}. The worktree was \
2503 kept for recovery.",
2504 cwd.display(),
2505 e.last_line()
2506 ),
2507 )
2508 })?;
2509 if after == baseline.attributes {
2510 return Ok(());
2511 }
2512 Err(uncertain_worktree_change(
2513 cwd,
2514 format!(
2515 "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
2516 refused to run a Git operation that could select a new external filter.",
2517 cwd.display()
2518 ),
2519 ))
2520 }
2521
2522 pub(crate) fn commit_pending_changes(
2527 &self,
2528 cwd: &Path,
2529 baseline: &WorktreeBaseline,
2530 preferred_subject: &str,
2531 fallback_subject: &str,
2532 ) -> Result<bool> {
2533 let mut artifacts = GeneratedArtifacts::default();
2534 self.refuse_changed_attributes(cwd, baseline)?;
2535 artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2536 refuse_unsafe_index_flags(cwd)?;
2537 if !self.has_uncommitted_changes(cwd)? {
2538 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2539 artifacts.report(cwd);
2540 return Ok(false);
2541 }
2542 self.stage_managed_changes(cwd, baseline).map_err(|e| {
2543 e.with_message(format!(
2544 "could not stage changes in {}: {}",
2545 cwd.display(),
2546 e.last_line()
2547 ))
2548 })?;
2549 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2552 let changed_gitlinks = changed_staged_gitlinks(cwd)?;
2553 if !changed_gitlinks.is_empty() {
2554 let listed = changed_gitlinks
2555 .iter()
2556 .take(5)
2557 .map(|path| format!("{:?}", path.as_os_str()))
2558 .collect::<Vec<_>>()
2559 .join(", ");
2560 bail!(
2561 "the editing call added or changed a gitlink at {listed}. It was staged but not \
2562 committed because the referenced repository objects might exist only inside \
2563 this worktree. The worktree was kept for recovery."
2564 );
2565 }
2566 let mut subject = self.clean_title(preferred_subject)?;
2567 if subject.trim().is_empty() {
2568 subject = self.clean_title(fallback_subject)?;
2569 }
2570 self.commit_staged_changes(cwd, &subject).map_err(|e| {
2571 e.with_message(format!(
2572 "could not commit changes in {}: {}. The staged files were kept.",
2573 cwd.display(),
2574 e.last_line()
2575 ))
2576 })?;
2577 if has_tracked_or_staged_work(cwd)? {
2578 bail!(
2579 "the commit in {} left additional uncommitted files. They were kept for \
2580 recovery.",
2581 cwd.display()
2582 );
2583 }
2584 artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2585 artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2586 artifacts.report(cwd);
2587 Ok(true)
2588 }
2589
2590 fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
2591 let after = ignored_untracked_state(cwd)?;
2592 self.git_at_without_automation(cwd, &["add", "-u"])?;
2593 let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
2594 if paths.is_empty() {
2595 return Ok(());
2596 }
2597 let mut input = Vec::new();
2598 for path in paths {
2599 input.extend(os_str_bytes(path.as_os_str())?);
2600 input.push(0);
2601 }
2602 let argv = git_without_automation_argv(&[
2603 "--literal-pathspecs",
2604 "add",
2605 "--pathspec-from-file=-",
2606 "--pathspec-file-nul",
2607 ]);
2608 proc::run_with_input_bytes(
2609 &argv,
2610 &self.git_opts(Some(cwd), true).stop_descendants(true),
2611 &input,
2612 )?;
2613 Ok(())
2614 }
2615
2616 pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
2619 self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
2620 .map(|_| ())
2621 }
2622
2623 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
2630 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2631 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
2632 .trim()
2633 .parse()
2634 .unwrap_or(0)
2635 }
2636
2637 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2641 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2642 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2643 .lines()
2644 .map(str::to_string)
2645 .collect()
2646 }
2647
2648 pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
2663 let ancestor = self
2664 .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
2665 .is_ok();
2666 if !ancestor {
2667 return None;
2668 }
2669 let range = format!("{earlier}..{later}");
2670 Some(
2671 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2672 .lines()
2673 .map(str::to_string)
2674 .collect(),
2675 )
2676 }
2677
2678 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2681 let range = format!("{}..{refname}", self.base_ref(cwd, base));
2682 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
2683 .lines()
2684 .map(str::trim)
2685 .filter(|line| !line.is_empty())
2686 .map(str::to_string)
2687 .collect()
2688 }
2689
2690 pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
2706 let range = format!("{}...HEAD", self.base_ref(cwd, base));
2707 self.git_try_at(
2708 Some(cwd),
2709 &["diff", "--name-only", "--no-renames", "-z", &range],
2710 )
2711 .split('\0')
2712 .filter(|path| !path.is_empty())
2713 .map(str::to_string)
2714 .collect()
2715 }
2716
2717 pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
2720 let base_ref = self.base_ref(cwd, base);
2721 let out = self
2722 .git_at(Some(cwd), &["merge-base", &base_ref, refname])
2723 .map_err(|e| {
2724 spar_err!(
2725 "could not find where {refname} and {base_ref} diverged. {}",
2726 e.last_line()
2727 )
2728 })?;
2729 let sha = out.trim().to_string();
2730 if sha.is_empty() {
2731 bail!("{refname} and {base_ref} share no history");
2732 }
2733 Ok(sha)
2734 }
2735
2736 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
2737 let range = format!("{}...HEAD", self.base_ref(cwd, base));
2738 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
2739 full.trim().to_string()
2740 }
2741
2742 pub fn rewrite_commits_if_needed(&self, cwd: &Path, base: &str) -> Result<()> {
2747 let range = format!("{}..HEAD", self.base_ref(cwd, base));
2748 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
2749
2750 let offenders = raw
2751 .split('\x1e')
2752 .filter_map(|entry| entry.split_once('\0'))
2753 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
2754 .count();
2755 if offenders == 0 {
2756 return Ok(());
2757 }
2758 logdim!("{offenders} commit message(s) violated style rules, rewriting");
2759
2760 let exe = self_binary()?;
2761 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
2762
2763 let argv: Vec<String> = [
2764 "git",
2765 "filter-branch",
2766 "-f",
2767 "--msg-filter",
2768 &filter,
2769 &range,
2770 ]
2771 .iter()
2772 .map(|s| s.to_string())
2773 .collect();
2774 let opts = ExecOpts::new()
2775 .cwd(cwd)
2776 .check(false)
2777 .timeout_secs(600)
2778 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
2779 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
2780 .env(
2781 "SPAR_BAN_AI_ATTRIBUTION",
2782 bool_env(self.style.ban_ai_attribution),
2783 );
2784 let _ = proc::run(&argv, &opts);
2785
2786 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
2787 if !style::violations(&after, &self.style).is_empty() {
2788 bail!(
2789 "commit messages still violate style rules after a rewrite in {}.",
2790 cwd.display()
2791 );
2792 }
2793 Ok(())
2794 }
2795
2796 pub fn push(&self, cwd: &Path, branch: &str) -> Result<()> {
2802 let refspec = format!("HEAD:{branch}");
2803 let pushed = self
2804 .git_at(
2805 Some(cwd),
2806 &["push", "--force-with-lease", "origin", &refspec],
2807 )
2808 .map(|_| ())
2809 .map_err(|e| {
2810 spar_err!(
2811 "could not push to origin/{branch}. {}\nCheck push access and whether the \
2812 branch moved under you.",
2813 e.last_line()
2814 )
2815 });
2816 self.record_write(pushed)
2817 }
2818
2819 pub fn push_split_branch(
2828 &self,
2829 cwd: &Path,
2830 branch: &str,
2831 ) -> std::result::Result<(), SplitPushError> {
2832 let remote_ref = format!("refs/heads/{branch}");
2833 let lease = format!("--force-with-lease={remote_ref}:");
2834 let refspec = format!("HEAD:{remote_ref}");
2835 let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
2836 Ok(_) => Ok(()),
2837 Err(push_error) => {
2838 let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
2839 let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
2840 reconcile_failed_split_push(branch, push_error, local, remote)
2841 }
2842 };
2843 self.record_write(result)
2844 }
2845
2846 pub fn gh(&self, args: &[&str]) -> Result<String> {
2849 self.gh_at(None, args)
2850 }
2851
2852 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
2853 let mut argv = vec!["gh".to_string()];
2854 argv.extend(args.iter().map(|s| s.to_string()));
2855 proc::run(
2856 &argv,
2857 &ExecOpts::new()
2858 .cwd(cwd.unwrap_or(&self.root))
2859 .timeout_secs(300),
2860 )
2861 }
2862
2863 pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
2869 let mut argv = vec!["gh".to_string()];
2870 argv.extend(args.iter().map(|s| s.to_string()));
2871 proc::run(
2872 &argv,
2873 &ExecOpts::new()
2874 .cwd(&self.root)
2875 .timeout_secs(300)
2876 .stdin(stdin),
2877 )
2878 }
2879
2880 pub fn gh_try(&self, args: &[&str]) -> String {
2881 let mut argv = vec!["gh".to_string()];
2882 argv.extend(args.iter().map(|s| s.to_string()));
2883 proc::run(
2884 &argv,
2885 &ExecOpts::new()
2886 .cwd(&self.root)
2887 .check(false)
2888 .timeout_secs(300),
2889 )
2890 .unwrap_or_default()
2891 }
2892
2893 pub fn viewer_login(&self) -> Result<&str> {
2904 if let Some(login) = self.viewer.get() {
2905 return Ok(login);
2906 }
2907 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
2908 let login = if !rest.trim().is_empty() {
2909 rest.trim().to_string()
2910 } else {
2911 self.gh(&[
2914 "api",
2915 "graphql",
2916 "-f",
2917 "query={ viewer { login } }",
2918 "--jq",
2919 ".data.viewer.login",
2920 ])
2921 .map_err(|e| {
2922 spar_err!(
2923 "could not find out who `gh` is authenticated as, so spar cannot tell its \
2924 own comments from anybody else's. {}\nRun `gh auth status`.",
2925 e.last_line()
2926 )
2927 })?
2928 .trim()
2929 .to_string()
2930 };
2931 if login.is_empty() {
2932 bail!("`gh` reported an empty login. Run `gh auth status`.");
2933 }
2934 Ok(self.viewer.get_or_init(|| login))
2935 }
2936
2937 pub fn read_issue(&self, number: i64) -> Result<Issue> {
2943 let text = self
2944 .gh(&[
2945 "issue",
2946 "view",
2947 &number.to_string(),
2948 "--json",
2949 "number,title,body,labels,state,url",
2950 ])
2951 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
2952 serde_json::from_str(&text)
2953 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
2954 }
2955
2956 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
2957 let mut issues = Vec::new();
2958 for number in numbers {
2959 let text = self
2960 .gh(&[
2961 "issue",
2962 "view",
2963 &number.to_string(),
2964 "--json",
2965 "number,title,body,labels,state,url",
2966 ])
2967 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
2968 let issue: Issue = serde_json::from_str(&text)
2969 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
2970 if issue.is_closed() {
2971 crate::log!("issue #{number} is closed, skipping");
2972 continue;
2973 }
2974 issues.push(issue);
2975 }
2976 if issues.is_empty() {
2977 bail!("no open issues to work on");
2978 }
2979 Ok(issues)
2980 }
2981
2982 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
2988 #[derive(Deserialize)]
2989 struct Row {
2990 number: i64,
2991 }
2992 let text = self.gh(&[
2993 kind,
2994 "list",
2995 "--state",
2996 "open",
2997 "--limit",
2998 &FETCH_CEILING.to_string(),
2999 "--json",
3000 "number",
3001 ])?;
3002 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3003 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3004 numbers.sort_unstable();
3005
3006 let noun = if kind == "issue" { "issues" } else { "PRs" };
3007 let found = numbers.len();
3008 if min_number > 0 {
3009 numbers.retain(|n| *n >= min_number);
3010 let skipped = found - numbers.len();
3011 if skipped > 0 {
3012 crate::log!("{skipped} open {noun} below #{min_number} skipped");
3013 }
3014 }
3015 if found >= FETCH_CEILING {
3016 crate::log!(
3017 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3018 considered."
3019 );
3020 }
3021 if numbers.len() > limit {
3022 crate::log!(
3023 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3024 explicitly.",
3025 numbers.len()
3026 );
3027 numbers.truncate(limit);
3028 }
3029 Ok(numbers)
3030 }
3031
3032 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3034 self.open_numbers("issue", limit, min_number)
3035 }
3036
3037 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3038 self.open_numbers("pr", limit, min_number)
3039 }
3040
3041 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3042 self.branch_prs(branch, "open").into_iter().next()
3043 }
3044
3045 pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3048 let text = self.gh(&[
3049 "pr",
3050 "list",
3051 "--head",
3052 branch,
3053 "--base",
3054 base,
3055 "--state",
3056 "open",
3057 "--json",
3058 "number,url,title,baseRefName",
3059 ])?;
3060 pr_for_base(&text, branch, base)
3061 }
3062
3063 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3067 self.branch_prs(branch, "all")
3068 }
3069
3070 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3071 let text = self.gh_try(&[
3072 "pr",
3073 "list",
3074 "--head",
3075 branch,
3076 "--state",
3077 state,
3078 "--json",
3079 "number,url,title",
3080 ]);
3081 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3082 }
3083
3084 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3090 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3091 let text = self
3092 .gh(&[
3093 "api",
3094 &path,
3095 "--jq",
3096 "if .pull_request then \"pr\" else \"issue\" end",
3097 ])
3098 .map_err(|e| {
3099 spar_err!(
3100 "no issue or pull request #{number} in this repository. {}",
3101 e.last_line()
3102 )
3103 })?;
3104 match text.trim() {
3105 "pr" => Ok(ItemKind::Pr),
3106 "issue" => Ok(ItemKind::Issue),
3107 other => Err(spar_err!(
3108 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3109 )),
3110 }
3111 }
3112
3113 pub fn open_pr_for_issue(&self, issue: i64) -> Option<PrRef> {
3119 if let Some(pr) = self.pr_for_branch(&self.branch_for_issue(issue)) {
3120 return Some(pr);
3121 }
3122 let text = self.gh_try(&[
3123 "pr",
3124 "list",
3125 "--state",
3126 "open",
3127 "--limit",
3128 &FETCH_CEILING.to_string(),
3129 "--json",
3130 "number,url,title,closingIssuesReferences",
3131 ]);
3132 find_linked_pr(&text, issue)
3133 }
3134
3135 pub fn pr_view(&self, number: i64) -> Result<PrView> {
3136 let text = self.gh(&[
3137 "pr",
3138 "view",
3139 &number.to_string(),
3140 "--json",
3141 "number,url,title,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3142 ])?;
3143 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3144 }
3145
3146 fn try_pr_state(&self, number: i64) -> Result<String> {
3147 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3148 serde_json::from_str::<Value>(text.trim())
3149 .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3150 .get("state")
3151 .and_then(Value::as_str)
3152 .map(str::to_string)
3153 .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3154 }
3155
3156 pub fn pr_state(&self, number: i64) -> String {
3157 self.try_pr_state(number).unwrap_or_default()
3158 }
3159
3160 pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3162 let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3163 let oid = serde_json::from_str::<Value>(&text)
3164 .ok()
3165 .and_then(|value| {
3166 value
3167 .get("headRefOid")
3168 .and_then(Value::as_str)
3169 .map(str::trim)
3170 .filter(|oid| !oid.is_empty())
3171 .map(str::to_string)
3172 })
3173 .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3174 Ok(oid)
3175 }
3176
3177 pub fn create_pr(
3178 &self,
3179 cwd: &Path,
3180 branch: &str,
3181 base: &str,
3182 title: &str,
3183 body: &str,
3184 ) -> Result<PrRef> {
3185 let title = self.record_failed_write(self.clean_title(title))?;
3186 let body = self.record_failed_write(self.clean(body))?;
3187 let mut argv = vec![
3188 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3189 ];
3190 if self.drafts != Drafts::Never {
3191 argv.push("--draft");
3192 }
3193 let created = self.gh_at(Some(cwd), &argv);
3194 let found = self.try_pr_for_branch(branch, base);
3195 self.record_write(reconcile_pr_creation(branch, created, found))
3196 }
3197
3198 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3199 let body = self.record_failed_write(self.clean(body))?;
3200 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3201 if has_exact_comment(&comments, &body) {
3202 return Ok(());
3203 }
3204 let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3205 let result = match posted {
3206 Ok(_) => Ok(()),
3207 Err(post_error) => {
3208 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3209 }
3210 };
3211 self.record_write(result)
3212 }
3213
3214 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3215 let body = self.record_failed_write(self.clean(body))?;
3216 let comments = self.record_failed_write(self.try_issue_comments(number))?;
3217 if has_exact_comment(&comments, &body) {
3218 return Ok(());
3219 }
3220 let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3221 let result = match posted {
3222 Ok(_) => Ok(()),
3223 Err(post_error) => {
3224 reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3225 }
3226 };
3227 self.record_write(result)
3228 }
3229
3230 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3235 self.comment_issue(number, body)?;
3236 let n = number.to_string();
3237 let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3238 Ok(_) => Ok(()),
3239 Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3241 spar_err!(
3242 "commented on #{number} but could not close it: {}",
3243 e.last_line()
3244 )
3245 }),
3246 };
3247 self.record_write(closed)
3248 }
3249
3250 pub fn edit_issue_body(
3265 &self,
3266 number: i64,
3267 expected: &str,
3268 body: &str,
3269 inserted: &str,
3270 ) -> Result<()> {
3271 let cleaned = self.record_failed_write(self.clean(inserted))?;
3272 if cleaned.trim() != inserted.trim() {
3273 return self.record_failed_write(Err(spar_err!(
3274 "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
3275 )));
3276 }
3277 let current = self.record_failed_write(self.issue_body(number))?;
3278 if current != expected {
3279 return self.record_failed_write(Err(spar_err!(
3280 "the body of #{number} changed since it was read, so it was left alone rather \
3281 than written over."
3282 )));
3283 }
3284 let edited = self.gh_stdin(
3285 &["issue", "edit", &number.to_string(), "--body-file", "-"],
3286 body,
3287 );
3288 let result = match edited {
3289 Ok(_) => Ok(()),
3290 Err(edit_error) => {
3291 reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
3292 }
3293 };
3294 self.record_write(result)
3295 }
3296
3297 pub fn issue_body(&self, number: i64) -> Result<String> {
3299 #[derive(Deserialize)]
3300 struct Row {
3301 #[serde(default)]
3302 body: Option<String>,
3303 }
3304 let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
3305 let row: Row = serde_json::from_str(text.trim())
3306 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3307 Ok(row.body.unwrap_or_default())
3308 }
3309
3310 pub fn open_issue_rows(&self) -> Vec<Issue> {
3315 let text = self.gh_try(&[
3316 "issue",
3317 "list",
3318 "--state",
3319 "open",
3320 "--limit",
3321 &FETCH_CEILING.to_string(),
3322 "--json",
3323 "number,title,body,labels,state,url",
3324 ]);
3325 serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
3326 }
3327
3328 pub fn open_pr_rows(&self) -> Vec<PrRow> {
3330 let text = self.gh_try(&[
3331 "pr",
3332 "list",
3333 "--state",
3334 "open",
3335 "--limit",
3336 &FETCH_CEILING.to_string(),
3337 "--json",
3338 "number,title,changedFiles,additions,deletions",
3339 ]);
3340 serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
3341 }
3342
3343 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
3344 self.create_issue_apart_from(title, body, None)
3345 }
3346
3347 pub fn create_issue_apart_from(
3348 &self,
3349 title: &str,
3350 body: &str,
3351 apart_from: Option<i64>,
3352 ) -> Result<String> {
3353 let title = self.record_failed_write(self.clean_title(title))?;
3354 let body = self.record_failed_write(self.clean_issue_body(body))?;
3355 let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
3356 let result = match created {
3357 Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
3358 created => {
3359 let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
3360 reconcile_issue_creation(&title, created, found)
3361 }
3362 };
3363 self.record_write(result)
3364 }
3365}
3366
3367#[derive(Debug, Clone)]
3369pub struct ExistingIssue {
3370 pub number: i64,
3371 pub url: String,
3372 pub title: String,
3373 pub body: String,
3374 pub open: bool,
3375}
3376
3377impl Repo {
3378 pub(crate) fn try_exact_issue_apart_from(
3379 &self,
3380 title: &str,
3381 body: &str,
3382 apart_from: Option<i64>,
3383 ) -> Result<Option<ExistingIssue>> {
3384 #[derive(Deserialize)]
3385 #[serde(rename_all = "camelCase")]
3386 struct Row {
3387 number: i64,
3388 #[serde(default)]
3389 title: String,
3390 #[serde(default)]
3391 url: String,
3392 #[serde(default)]
3393 body: Option<String>,
3394 #[serde(default)]
3395 state: String,
3396 }
3397
3398 let text = self.gh(&[
3399 "issue",
3400 "list",
3401 "--state",
3402 "all",
3403 "--limit",
3404 "100",
3405 "--json",
3406 "number,title,url,body,state",
3407 ])?;
3408 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3409 .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
3410 Ok(rows
3411 .into_iter()
3412 .filter(|row| Some(row.number) != apart_from)
3413 .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
3414 .map(|row| ExistingIssue {
3415 number: row.number,
3416 url: row.url,
3417 title: row.title,
3418 body: row.body.unwrap_or_default(),
3419 open: row.state.eq_ignore_ascii_case("open"),
3420 }))
3421 }
3422
3423 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
3431 self.find_similar_issue_apart_from(title, body, None)
3432 }
3433
3434 pub fn find_similar_issue_apart_from(
3440 &self,
3441 title: &str,
3442 body: &str,
3443 apart_from: Option<i64>,
3444 ) -> Option<ExistingIssue> {
3445 self.try_find_similar_issue_apart_from(title, body, apart_from)
3446 .ok()
3447 .flatten()
3448 }
3449
3450 pub fn try_find_similar_issue_apart_from(
3452 &self,
3453 title: &str,
3454 body: &str,
3455 apart_from: Option<i64>,
3456 ) -> Result<Option<ExistingIssue>> {
3457 #[derive(Deserialize)]
3458 #[serde(rename_all = "camelCase")]
3459 struct Row {
3460 number: i64,
3461 #[serde(default)]
3462 title: String,
3463 #[serde(default)]
3464 url: String,
3465 #[serde(default)]
3466 body: String,
3467 #[serde(default)]
3468 state: String,
3469 }
3470 if title.trim().is_empty() {
3471 return Ok(None);
3472 }
3473 let query: String = title
3476 .chars()
3477 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3478 .take(120)
3479 .collect();
3480 let text = self.gh(&[
3481 "issue",
3482 "list",
3483 "--state",
3484 "all",
3485 "--limit",
3486 "100",
3487 "--search",
3488 query.trim(),
3489 "--json",
3490 "number,title,url,body,state",
3491 ])?;
3492 let rows: Vec<Row> = serde_json::from_str(text.trim())
3493 .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
3494 let wanted = format!("{title} {body}");
3495
3496 Ok(rows
3497 .into_iter()
3498 .filter(|row| Some(row.number) != apart_from)
3499 .find(|row| {
3500 let theirs = format!("{} {}", row.title, row.body);
3501 row.title.trim().eq_ignore_ascii_case(title.trim())
3502 || textsim::same_subject(&wanted, &theirs)
3503 })
3504 .map(|row| ExistingIssue {
3505 number: row.number,
3506 url: row.url,
3507 title: row.title,
3508 open: row.state.eq_ignore_ascii_case("open"),
3509 body: row.body,
3510 }))
3511 }
3512
3513 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
3515 #[derive(Deserialize)]
3516 struct Row {
3517 title: String,
3518 url: String,
3519 }
3520 let needle = title.trim().to_lowercase();
3521 if needle.is_empty() {
3522 return None;
3523 }
3524 let query: String = title
3526 .chars()
3527 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3528 .take(120)
3529 .collect();
3530 let text = self.gh_try(&[
3531 "issue",
3532 "list",
3533 "--state",
3534 "all",
3535 "--limit",
3536 "100",
3537 "--search",
3538 query.trim(),
3539 "--json",
3540 "number,title,url",
3541 ]);
3542 serde_json::from_str::<Vec<Row>>(text.trim())
3543 .ok()?
3544 .into_iter()
3545 .find(|row| row.title.trim().to_lowercase() == needle)
3546 .map(|row| row.url)
3547 }
3548
3549 pub fn mark_ready(&self, number: i64) -> bool {
3557 match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
3558 Ok(_) => true,
3559 Err(e) => {
3560 logdim!(
3561 "PR #{number} is approved but could not be taken out of draft: {}",
3562 e.last_line()
3563 );
3564 false
3565 }
3566 }
3567 }
3568
3569 pub fn merge_pr(&self, number: i64) -> Result<()> {
3573 let n = number.to_string();
3574 let merged = match self.gh(&merge_pr_args(&n, None, true)) {
3575 Ok(_) => Ok(()),
3576 Err(e) => {
3577 if self.pr_state(number) == "MERGED" {
3578 logdim!(
3579 "PR #{number} merged; branch cleanup did not finish: {}",
3580 e.last_line()
3581 );
3582 Ok(())
3583 } else {
3584 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3585 }
3586 }
3587 };
3588 self.record_write(merged)
3589 }
3590
3591 pub fn merge_pr_at_head(
3593 &self,
3594 number: i64,
3595 expected_head: &str,
3596 delete_branch: bool,
3597 ) -> Result<()> {
3598 let n = number.to_string();
3599 let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
3600 Ok(_) => Ok(()),
3601 Err(e) => {
3602 if self.pr_state(number) == "MERGED" {
3603 logdim!(
3604 "PR #{number} merged; branch cleanup did not finish: {}",
3605 e.last_line()
3606 );
3607 Ok(())
3608 } else {
3609 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3610 }
3611 }
3612 };
3613 self.record_write(merged)
3614 }
3615
3616 pub fn followups_path(&self) -> PathBuf {
3621 self.root.join(STATE_DIR).join("followups.md")
3622 }
3623
3624 pub fn worked_followups_path(&self) -> PathBuf {
3632 self.root.join(STATE_DIR).join("followups.done.md")
3633 }
3634
3635 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
3637 self.root
3638 .join(STATE_DIR)
3639 .join("state")
3640 .join(format!("checkin-{number}.json"))
3641 }
3642
3643 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
3658 let path = self.followups_path();
3659 let heading = format!("## {}", title.trim());
3660 for seen in [&path, &self.worked_followups_path()] {
3661 if let Ok(existing) = std::fs::read_to_string(seen) {
3662 if existing.contains(&heading) {
3663 logdim!("follow-up already noted: {title}");
3664 return Followup::Covered(format!("note: {}", title.trim()));
3665 }
3666 }
3667 }
3668 if let Some(parent) = path.parent() {
3669 let _ = std::fs::create_dir_all(parent);
3670 }
3671 use std::io::Write;
3672 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
3679 match std::fs::OpenOptions::new()
3680 .create(true)
3681 .append(true)
3682 .open(&path)
3683 {
3684 Ok(mut file) => match file.write_all(entry.as_bytes()) {
3685 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
3686 Err(e) => {
3687 logdim!("could not write {}: {e}", path.display());
3688 Followup::Failed
3689 }
3690 },
3691 Err(e) => {
3692 logdim!("could not write {}: {e}", path.display());
3693 Followup::Failed
3694 }
3695 }
3696 }
3697
3698 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
3703 let path = self.worked_followups_path();
3704 if let Some(parent) = path.parent() {
3705 let _ = std::fs::create_dir_all(parent);
3706 }
3707 use std::io::Write;
3708 let entry = format!(
3709 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
3710 title.trim(),
3711 body.trim()
3712 );
3713 if let Ok(mut file) = std::fs::OpenOptions::new()
3714 .create(true)
3715 .append(true)
3716 .open(&path)
3717 {
3718 let _ = file.write_all(entry.as_bytes());
3719 }
3720 }
3721
3722 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
3731 self.root
3732 .join(STATE_DIR)
3733 .join("reviews")
3734 .join(format!("pr-{number}.md"))
3735 }
3736
3737 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
3743 let path = self.pending_comment_path(number);
3744 if let Some(parent) = path.parent() {
3745 std::fs::create_dir_all(parent)
3746 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
3747 }
3748 std::fs::write(&path, text)
3749 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
3750 Ok(path)
3751 }
3752
3753 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
3754 std::fs::read_to_string(self.pending_comment_path(number)).ok()
3755 }
3756
3757 pub fn state_path(&self, number: i64) -> PathBuf {
3758 self.root
3759 .join(STATE_DIR)
3760 .join("state")
3761 .join(format!("pr-{number}.json"))
3762 }
3763
3764 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
3765 let path = self.state_path(number);
3766 let text = std::fs::read_to_string(&path).ok()?;
3767 match serde_json::from_str(&text) {
3768 Ok(state) => Some(state),
3769 Err(_) => {
3770 logdim!("could not read {}, starting fresh", path.display());
3771 None
3772 }
3773 }
3774 }
3775
3776 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
3777 if let Some(local) = self.read_local_state(pr.number) {
3778 return Some(local);
3779 }
3780 if self.state_store.writes_pr() {
3781 return self.read_pr_state(pr.number);
3782 }
3783 None
3784 }
3785
3786 pub(crate) fn read_state_for_head(
3787 &self,
3788 pr: &PrView,
3789 actual_head: &str,
3790 ) -> Option<PersistedState> {
3791 let local = self
3792 .state_store
3793 .writes_local()
3794 .then(|| self.read_local_state(pr.number))
3795 .flatten();
3796 let remote = self
3797 .state_store
3798 .writes_pr()
3799 .then(|| self.read_pr_state(pr.number))
3800 .flatten();
3801 let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
3802 if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
3803 self.remember_checkpoint(pr.number, checkpoint);
3804 }
3805 choose_state_for_head(candidates, actual_head)
3806 }
3807
3808 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
3809 self.try_read_pr_state(number).ok().flatten()
3810 }
3811
3812 fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
3813 for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
3814 if let Some(state) = parse_state_comment(&body) {
3815 return Ok(Some(state));
3816 }
3817 }
3818 Ok(None)
3819 }
3820
3821 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
3822 let remote_state = if self.state_store.writes_pr() {
3823 self.try_read_pr_state(number)
3824 } else {
3825 Ok(None)
3826 };
3827 self.write_state_after_remote_read(number, state, remote_state)
3828 }
3829
3830 fn write_state_after_remote_read(
3831 &self,
3832 number: i64,
3833 state: &PersistedState,
3834 remote_state: Result<Option<PersistedState>>,
3835 ) -> Result<()> {
3836 let remote_checkpoint = if self.state_store.writes_pr() {
3837 self.record_failed_write(remote_state)?
3838 .map(|saved| saved.checkpoint)
3839 .unwrap_or_default()
3840 } else {
3841 0
3842 };
3843 let local_checkpoint = self
3844 .state_store
3845 .writes_local()
3846 .then(|| self.read_local_state(number))
3847 .flatten()
3848 .map(|saved| saved.checkpoint)
3849 .unwrap_or_default();
3850 let mut stamped = state.clone();
3851 stamped.checkpoint = state
3852 .checkpoint
3853 .max(local_checkpoint)
3854 .max(remote_checkpoint)
3855 .max(self.remembered_checkpoint(number))
3856 .saturating_add(1);
3857 self.remember_checkpoint(number, stamped.checkpoint);
3858 if self.state_store.writes_local() {
3859 write_json_atomic(&self.state_path(number), &stamped)?;
3860 }
3861 if self.state_store.writes_pr() {
3862 self.write_pr_state(number, &stamped)?;
3863 }
3864 Ok(())
3865 }
3866
3867 fn remembered_checkpoint(&self, number: i64) -> u64 {
3868 self.checkpoints
3869 .lock()
3870 .unwrap_or_else(std::sync::PoisonError::into_inner)
3871 .get(&number)
3872 .copied()
3873 .unwrap_or_default()
3874 }
3875
3876 fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
3877 let mut checkpoints = self
3878 .checkpoints
3879 .lock()
3880 .unwrap_or_else(std::sync::PoisonError::into_inner);
3881 let saved = checkpoints.entry(number).or_default();
3882 *saved = (*saved).max(checkpoint);
3883 }
3884
3885 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
3886 let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
3890 let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
3891 let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
3892 if let Some(id) = comment_id {
3893 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
3894 let field = format!("body={body}");
3895 let written = self
3896 .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
3897 .map(|_| ());
3898 return self.record_write(written);
3899 }
3900 let written = self
3901 .gh(&["pr", "comment", &number.to_string(), "--body", &body])
3902 .map(|_| ());
3903 self.record_write(written)
3904 }
3905
3906 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
3914 self.try_issue_comments(number).unwrap_or_default()
3915 }
3916
3917 pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
3918 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
3919 try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
3920 }
3921
3922 fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
3923 Ok(self
3924 .try_issue_comments(number)?
3925 .into_iter()
3926 .filter_map(|c| {
3927 let body = c.get("body").and_then(Value::as_str)?.to_string();
3928 if !body.contains("spar:state") {
3929 return None;
3930 }
3931 let id = c.get("id").and_then(Value::as_i64)?;
3932 Some((id, body))
3933 })
3934 .collect())
3935 }
3936
3937 fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
3938 Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
3939 }
3940
3941 pub fn clear_state(&self, number: i64) {
3943 let path = self.state_path(number);
3944 let _ = std::fs::remove_file(&path);
3945 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
3946 }
3947
3948 pub fn prune_state(&self) -> Vec<String> {
3952 let base = self.root.join(STATE_DIR).join("state");
3953 let Ok(entries) = std::fs::read_dir(&base) else {
3954 return Vec::new();
3955 };
3956 let mut names: Vec<String> = entries
3957 .flatten()
3958 .filter_map(|e| e.file_name().to_str().map(str::to_string))
3959 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
3960 .collect();
3961 names.sort();
3962
3963 let mut removed = Vec::new();
3964 for name in names {
3965 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
3966 continue;
3967 };
3968 if is_finished(&self.pr_state(number)) {
3969 let _ = std::fs::remove_file(base.join(&name));
3970 removed.push(format!("state {name}"));
3971 }
3972 }
3973 removed
3974 }
3975
3976 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
3980 #[derive(Deserialize)]
3981 struct Row {
3982 number: i64,
3983 }
3984 let numbers = match numbers {
3985 Some(numbers) => numbers,
3986 None => {
3987 let listed: Result<Vec<i64>> = (|| {
3988 let text = self.gh(&[
3989 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
3990 ])?;
3991 let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3992 .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
3993 Ok(rows.into_iter().map(|row| row.number).collect())
3994 })();
3995 match self.record_failed_write(listed) {
3996 Ok(numbers) => numbers,
3997 Err(e) => {
3998 logdim!("could not inspect pull requests for state cleanup: {e}");
3999 return Vec::new();
4000 }
4001 }
4002 }
4003 };
4004
4005 let mut removed = Vec::new();
4006 for number in numbers {
4007 let state = match self.record_failed_write(self.try_pr_state(number)) {
4008 Ok(state) => state,
4009 Err(e) => {
4010 logdim!("could not inspect PR #{number} for state cleanup: {e}");
4011 continue;
4012 }
4013 };
4014 if !is_finished(&state) {
4015 continue;
4016 }
4017 let comments = match self.record_failed_write(self.try_state_comments(number)) {
4018 Ok(comments) => comments,
4019 Err(e) => {
4020 logdim!("could not inspect state comments on PR #{number}: {e}");
4021 continue;
4022 }
4023 };
4024 for (id, _) in comments {
4025 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4026 let deleted = self
4027 .gh(&["api", "-X", "DELETE", &path, "--silent"])
4028 .map(|_| ());
4029 match self.record_write(deleted) {
4030 Ok(()) => removed.push(format!("state comment on PR #{number}")),
4031 Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
4032 }
4033 }
4034 }
4035 removed
4036 }
4037
4038 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
4045 let base = self.root.join(WORKTREE_DIR);
4046 let mut removed = Vec::new();
4047 let known = self.known_branches();
4048
4049 if let Ok(entries) = std::fs::read_dir(&base) {
4050 let mut names: Vec<String> = entries
4051 .flatten()
4052 .filter(|e| e.path().is_dir())
4053 .filter_map(|e| e.file_name().to_str().map(str::to_string))
4054 .collect();
4055 names.sort();
4056
4057 for name in names {
4058 if let Some(rest) = name.strip_prefix("review-") {
4061 let number: i64 = rest.parse().unwrap_or(-1);
4062 if !(force_all || is_finished(&self.pr_state(number))) {
4063 continue;
4064 }
4065 let path = base.join(&name);
4066 if force_all {
4067 let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4068 if !belongs {
4069 return Ok(false);
4070 }
4071 let local_ref = review_ref(number);
4072 if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4073 return Ok(false);
4074 }
4075 let head = self.head_oid_checked(&path)?;
4076 let recorded = self
4077 .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4078 .trim()
4079 .to_string();
4080 Ok(head == recorded)
4081 });
4082 match owned {
4083 Ok(true) => {}
4084 Ok(false) => {
4085 logdim!(
4086 "kept {} because no matching SPAR review reference proves \
4087 ownership",
4088 path.display()
4089 );
4090 continue;
4091 }
4092 Err(e) => {
4093 logdim!(
4094 "kept {} because review ownership could not be verified: {}",
4095 path.display(),
4096 e.last_line()
4097 );
4098 continue;
4099 }
4100 }
4101 } else {
4102 if let Err(e) = self.refuse_review_worktree_changes(number) {
4103 logdim!(
4104 "kept {} because its review state could not be verified as \
4105 disposable: {}",
4106 path.display(),
4107 e.last_line()
4108 );
4109 continue;
4110 }
4111 }
4112 if force_all {
4113 if self.remove_worktree_at_force(&path) {
4114 self.git_try(&["update-ref", "-d", &review_ref(number)]);
4115 }
4116 } else {
4117 self.release_review_worktree(number);
4118 }
4119 if !path.exists() {
4120 removed.push(name);
4121 }
4122 continue;
4123 }
4124 let branch = format!("{}{name}", self.branch_prefix);
4125 if !(force_all || self.worktree_is_done(&branch)) {
4126 continue;
4127 }
4128 if !known.contains_key(&branch) {
4129 logdim!("kept {branch} because it has no branch record");
4130 continue;
4131 }
4132 let path = base.join(&name);
4133 if !force_all {
4134 match self.has_recoverable_work(&path) {
4135 Ok(true) => {
4136 logdim!(
4137 "kept {} because it contains uncommitted changes or ignored files",
4138 path.display()
4139 );
4140 continue;
4141 }
4142 Err(e) => {
4143 logdim!(
4144 "kept {} because its Git state could not be checked: {}",
4145 path.display(),
4146 e.last_line()
4147 );
4148 continue;
4149 }
4150 Ok(false) => {}
4151 }
4152 match self.branch_deletion_is_safe(&branch) {
4153 Ok(true) => {}
4154 Ok(false) => {
4155 logdim!(
4156 "kept {branch} because no surviving ref preserves its tip or \
4157 reflog-only commits"
4158 );
4159 continue;
4160 }
4161 Err(e) => {
4162 logdim!(
4163 "kept {branch} because preservation could not be verified: {}",
4164 e.last_line()
4165 );
4166 continue;
4167 }
4168 }
4169 }
4170 let removed_worktree = if force_all {
4171 self.remove_worktree_at_force(&path)
4172 } else {
4173 match self.remove_worktree_at(&path) {
4174 Ok(removed) => removed,
4175 Err(error) => {
4176 logdim!(
4177 "kept {branch} and {} because removal did not reach a confirmed \
4178 quiet point: {}",
4179 path.display(),
4180 error.last_line()
4181 );
4182 false
4183 }
4184 }
4185 };
4186 if !removed_worktree {
4187 continue;
4188 }
4189 if force_all {
4190 self.git_try(&["branch", "-D", &branch]);
4191 self.forget_branch(&branch);
4192 } else {
4193 match self.delete_branch_if_safe(&branch) {
4194 Ok(true) => self.forget_branch(&branch),
4195 Ok(false) => logdim!(
4196 "kept {branch} because its tip or reflog changed before deletion"
4197 ),
4198 Err(error) => logdim!(
4199 "kept {branch} because deletion safety could not be rechecked: {}",
4200 error.last_line()
4201 ),
4202 }
4203 }
4204 removed.push(name);
4205 }
4206 }
4207 removed.extend(self.prune_branches(force_all));
4208 removed
4209 }
4210
4211 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
4218 let known = self.known_branches();
4219 let branches: Vec<String> = known.keys().cloned().collect();
4220 if branches.is_empty() {
4221 return Vec::new();
4222 }
4223
4224 let checked_out: Vec<String> = self
4225 .git_try(&["worktree", "list", "--porcelain"])
4226 .lines()
4227 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
4228 .collect();
4229
4230 let existing: Vec<String> = self
4233 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
4234 .lines()
4235 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
4236 .collect();
4237
4238 let mut removed = Vec::new();
4239 for branch in branches {
4240 if !existing.contains(&branch) {
4241 self.forget_branch(&branch); continue;
4243 }
4244 if checked_out.contains(&branch) {
4245 continue;
4246 }
4247 if !(force_all || self.worktree_is_done(&branch)) {
4248 continue;
4249 }
4250 if !force_all {
4251 let Some(_record) = known.get(&branch) else {
4252 continue;
4253 };
4254 match self.branch_deletion_is_safe(&branch) {
4255 Ok(true) => {}
4256 Ok(false) => {
4257 logdim!(
4258 "kept {branch} because no surviving ref preserves its tip or \
4259 reflog-only commits"
4260 );
4261 continue;
4262 }
4263 Err(e) => {
4264 logdim!(
4265 "kept {branch} because preservation could not be verified: {}",
4266 e.last_line()
4267 );
4268 continue;
4269 }
4270 }
4271 }
4272 let deleted = if force_all {
4273 self.git(&["branch", "-D", &branch]).map(|_| true)
4274 } else {
4275 self.delete_branch_if_safe(&branch)
4276 };
4277 match deleted {
4278 Ok(true) => {
4279 self.forget_branch(&branch);
4280 removed.push(format!("branch {branch}"));
4281 }
4282 Ok(false) => {
4283 logdim!("kept {branch} because its tip or reflog changed before deletion");
4284 }
4285 Err(e) => {
4286 logdim!("could not delete {branch}: {}", e.last_line());
4289 }
4290 }
4291 }
4292 removed
4293 }
4294
4295 fn worktree_is_done(&self, branch: &str) -> bool {
4297 #[derive(Deserialize)]
4298 struct Row {
4299 state: String,
4300 }
4301 let entry = branch
4302 .strip_prefix(self.branch_prefix.as_str())
4303 .unwrap_or(branch);
4304 if let Some(rest) = entry.strip_prefix("pr-") {
4305 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
4306 }
4307 if entry.starts_with("issue-") || entry.starts_with("split-") {
4311 let text = self.gh_try(&[
4312 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
4313 ]);
4314 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4315 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
4316 }
4317 false
4318 }
4319}
4320
4321pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
4331 let root = std::fs::canonicalize(cwd)
4332 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
4333 let mut files = BTreeMap::new();
4334 let mut visited = BTreeSet::new();
4335 collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
4336 Ok(AttributeState { files })
4337}
4338
4339fn collect_attribute_files(
4340 root: &Path,
4341 repository: &Path,
4342 prefix: &Path,
4343 visited: &mut BTreeSet<PathBuf>,
4344 files: &mut BTreeMap<PathBuf, [u8; 32]>,
4345) -> Result<()> {
4346 let canonical = std::fs::canonicalize(repository)
4347 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
4348 if !visited.insert(canonical) {
4349 bail!("submodule recursion revisited {}", repository.display());
4350 }
4351 let entries = index_entries(repository)?;
4352 let mut paths: BTreeSet<PathBuf> = entries
4353 .iter()
4354 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
4355 .map(|entry| entry.path.clone())
4356 .collect();
4357 let untracked = run_git_bytes(
4358 repository,
4359 &[
4360 "ls-files",
4361 "--others",
4362 "-z",
4363 "--",
4364 ".gitattributes",
4365 ":(glob)**/.gitattributes",
4366 ],
4367 )?;
4368 if !untracked.is_empty() && !untracked.ends_with(&[0]) {
4369 bail!(
4370 "git returned an unterminated attribute-file listing for {}",
4371 repository.display()
4372 );
4373 }
4374 for raw in untracked
4375 .split(|byte| *byte == 0)
4376 .filter(|record| !record.is_empty())
4377 {
4378 paths.insert(safe_git_path(raw, "attribute")?);
4379 }
4380 for path in paths {
4381 let from_root = prefix.join(&path);
4382 let state = attribute_file_fingerprint(&root.join(&from_root))?;
4383 files.insert(from_root, state);
4384 }
4385 for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
4386 let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
4387 continue;
4388 };
4389 collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
4390 }
4391 Ok(())
4392}
4393
4394pub(crate) fn uncertain_worktree_change(
4397 cwd: &Path,
4398 message: impl Into<String>,
4399) -> crate::error::SparError {
4400 let message = message.into();
4401 let marker = write_recovery_marker(cwd, &message);
4402 let note = match marker {
4403 Ok(path) => format!(" Recovery marker: {}.", path.display()),
4404 Err(e) => format!(
4405 " A recovery marker could not be written: {}.",
4406 e.last_line()
4407 ),
4408 };
4409 crate::error::SparError::uncertain_write(format!("{message}{note}"))
4410}
4411
4412fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
4413 use std::sync::atomic::{AtomicU32, Ordering};
4414 static NEXT: AtomicU32 = AtomicU32::new(0);
4415 for _ in 0..1000 {
4416 let serial = NEXT.fetch_add(1, Ordering::Relaxed);
4417 let path = cwd.join(format!(
4418 ".spar-recovery-needed-{}-{serial}",
4419 std::process::id()
4420 ));
4421 let mut options = OpenOptions::new();
4422 options.write(true).create_new(true);
4423 #[cfg(unix)]
4424 {
4425 use std::os::unix::fs::OpenOptionsExt;
4426 options.mode(0o600);
4427 }
4428 match options.open(&path) {
4429 Ok(mut file) => {
4430 file.write_all(detail.as_bytes())
4431 .and_then(|_| file.write_all(b"\n"))
4432 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4433 return Ok(path);
4434 }
4435 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
4436 Err(e) => {
4437 return Err(spar_err!(
4438 "could not create a recovery marker in {}: {e}",
4439 cwd.display()
4440 ))
4441 }
4442 }
4443 }
4444 bail!(
4445 "could not choose a free recovery marker name in {}",
4446 cwd.display()
4447 )
4448}
4449
4450fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
4455 let mut argv = vec![
4456 "git".to_string(),
4457 "-c".to_string(),
4458 "maintenance.auto=false".to_string(),
4459 "-c".to_string(),
4460 "gc.auto=0".to_string(),
4461 ];
4462 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4463 argv
4464}
4465
4466fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
4467 let mut argv = git_without_maintenance_argv(&[]);
4468 argv.extend([
4469 "-c".to_string(),
4470 "core.fsmonitor=".to_string(),
4471 "-c".to_string(),
4472 "commit.gpgsign=false".to_string(),
4473 "-c".to_string(),
4474 "core.hooksPath=/dev/null".to_string(),
4475 ]);
4476 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4477 argv
4478}
4479
4480pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
4487 let root = std::fs::canonicalize(cwd)
4488 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
4489 let mut files = BTreeMap::new();
4490 let mut ignored = BTreeSet::new();
4491 let mut visited = BTreeSet::new();
4492 collect_untracked_files(
4493 &root,
4494 &root,
4495 Path::new(""),
4496 &mut visited,
4497 &mut files,
4498 &mut ignored,
4499 )?;
4500 Ok(IgnoredState { files, ignored })
4501}
4502
4503fn collect_untracked_files(
4504 root: &Path,
4505 repository: &Path,
4506 prefix: &Path,
4507 visited: &mut BTreeSet<PathBuf>,
4508 files: &mut BTreeMap<PathBuf, UntrackedFile>,
4509 ignored: &mut BTreeSet<PathBuf>,
4510) -> Result<()> {
4511 let canonical = std::fs::canonicalize(repository)
4512 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
4513 if !visited.insert(canonical.clone()) {
4514 bail!("submodule recursion revisited {}", canonical.display());
4515 }
4516 let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
4517 if !listed.is_empty() && !listed.ends_with(&[0]) {
4518 bail!(
4519 "git returned an unterminated untracked-file list for {}",
4520 repository.display()
4521 );
4522 }
4523
4524 for raw in listed
4525 .split(|byte| *byte == 0)
4526 .filter(|raw| !raw.is_empty())
4527 {
4528 let (relative, nested) = untracked_record(raw, "untracked")?;
4529 let from_root = prefix.join(&relative);
4530 let absolute = root.join(&from_root);
4531 let fingerprint = if nested {
4532 nested_repository_fingerprint(&absolute)?
4533 } else {
4534 ignored_file_fingerprint(&absolute)?
4535 };
4536 if files.insert(from_root.clone(), fingerprint).is_some() {
4537 bail!(
4538 "git returned the untracked path more than once: {:?}",
4539 from_root
4540 );
4541 }
4542 }
4543
4544 let ignored_listed = run_git_bytes(
4545 repository,
4546 &[
4547 "ls-files",
4548 "--others",
4549 "--ignored",
4550 "--exclude-standard",
4551 "-z",
4552 ],
4553 )?;
4554 if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
4555 bail!(
4556 "git returned an unterminated ignored-file list for {}",
4557 repository.display()
4558 );
4559 }
4560 for raw in ignored_listed
4561 .split(|byte| *byte == 0)
4562 .filter(|raw| !raw.is_empty())
4563 {
4564 let (relative, _) = untracked_record(raw, "ignored")?;
4565 let from_root = prefix.join(relative);
4566 if !files.contains_key(&from_root) {
4567 bail!(
4568 "git classified an unlisted path as ignored: {:?}",
4569 from_root
4570 );
4571 }
4572 if !ignored.insert(from_root.clone()) {
4573 bail!(
4574 "git returned the ignored path more than once: {:?}",
4575 from_root
4576 );
4577 }
4578 }
4579
4580 for link in gitlinks(repository)? {
4581 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
4582 continue;
4583 };
4584 collect_untracked_files(
4585 root,
4586 &submodule,
4587 &prefix.join(&link.path),
4588 visited,
4589 files,
4590 ignored,
4591 )?;
4592 }
4593 Ok(())
4594}
4595
4596fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
4597 let argv = git_without_automation_argv(args);
4598 proc::run_bytes(
4599 &argv,
4600 &ExecOpts::new()
4601 .cwd(cwd)
4602 .timeout_secs(30)
4603 .stop_descendants(true),
4604 )
4605}
4606
4607fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
4608 let argv = git_without_automation_argv(args);
4609 proc::run(
4610 &argv,
4611 &ExecOpts::new()
4612 .cwd(cwd)
4613 .timeout_secs(30)
4614 .stop_descendants(true),
4615 )
4616}
4617
4618fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
4619 let path = path.to_str().ok_or_else(|| {
4620 spar_err!(
4621 "cannot verify filtered content for a non-UTF-8 path in {}",
4622 cwd.display()
4623 )
4624 })?;
4625 let path_arg = format!("--path={path}");
4626 let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
4627 Ok(Sha256::digest(bytes).into())
4628}
4629
4630fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
4631 let relative = path_from_git_bytes(raw)?;
4632 if relative.is_absolute()
4633 || relative.components().any(|component| {
4634 matches!(
4635 component,
4636 std::path::Component::ParentDir
4637 | std::path::Component::RootDir
4638 | std::path::Component::Prefix(_)
4639 )
4640 })
4641 {
4642 bail!("git returned an unsafe {kind} path: {:?}", relative);
4643 }
4644 Ok(relative)
4645}
4646
4647fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
4656 let nested = raw.last() == Some(&b'/');
4657 let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
4658 if trimmed.is_empty() {
4659 bail!("git returned an empty {kind} path");
4660 }
4661 Ok((safe_git_path(trimmed, kind)?, nested))
4662}
4663
4664fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
4665 let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
4666 if !listed.is_empty() && !listed.ends_with(&[0]) {
4667 bail!(
4668 "git returned an unterminated index listing for {}",
4669 cwd.display()
4670 );
4671 }
4672 let mut entries = Vec::new();
4673 for record in listed
4674 .split(|byte| *byte == 0)
4675 .filter(|record| !record.is_empty())
4676 {
4677 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
4678 bail!(
4679 "git returned a malformed index record for {}",
4680 cwd.display()
4681 );
4682 };
4683 let header = &record[..tab];
4684 let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
4685 if fields.len() != 3 {
4686 bail!(
4687 "git returned a malformed index header for {}",
4688 cwd.display()
4689 );
4690 }
4691 if fields[2] != b"0" {
4692 continue;
4693 }
4694 let mode = std::str::from_utf8(fields[0])
4695 .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
4696 .to_string();
4697 let oid = std::str::from_utf8(fields[1])
4698 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
4699 .to_string();
4700 entries.push(IndexEntry {
4701 path: safe_git_path(&record[tab + 1..], "index")?,
4702 mode,
4703 oid,
4704 });
4705 }
4706 Ok(entries)
4707}
4708
4709fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
4710 let untracked = run_git_bytes(
4711 cwd,
4712 &[
4713 "ls-files",
4714 "--others",
4715 "-z",
4716 "--",
4717 ".gitattributes",
4718 ":(glob)**/.gitattributes",
4719 ],
4720 )?;
4721 if !untracked.is_empty() {
4722 return Ok(true);
4723 }
4724
4725 let index = index_entries(cwd)?
4726 .into_iter()
4727 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
4728 .map(|entry| (entry.path, (entry.mode, entry.oid)))
4729 .collect::<BTreeMap<_, _>>();
4730 let head = tree_entries(cwd, "HEAD")?
4731 .into_iter()
4732 .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
4733 .map(|entry| (entry.path, (entry.mode, entry.oid)))
4734 .collect::<BTreeMap<_, _>>();
4735 if index != head {
4736 return Ok(true);
4737 }
4738
4739 let effective = check_attributes(cwd, index.keys().cloned())?;
4740 for (path, (_mode, oid)) in index {
4741 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
4742 return Ok(true);
4743 };
4744 let attributes = effective
4745 .get(&path)
4746 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
4747 if allows_expected_crlf(cwd, attributes)? {
4748 if worktree.mode == "120000" {
4749 return Ok(true);
4750 }
4751 let (normalized, every_lf_was_crlf) =
4752 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
4753 if !every_lf_was_crlf || normalized != oid {
4754 return Ok(true);
4755 }
4756 } else if worktree.raw_oid != oid {
4757 return Ok(true);
4758 }
4759 }
4760 Ok(false)
4761}
4762
4763fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
4764 Ok(index_entries(cwd)?
4765 .into_iter()
4766 .filter(|entry| entry.mode == "160000")
4767 .map(|entry| Gitlink {
4768 path: entry.path,
4769 oid: entry.oid,
4770 })
4771 .collect())
4772}
4773
4774fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
4775 let mut tracked = BTreeMap::new();
4776 for entry in index_entries(cwd)? {
4777 if entry.mode == "160000" {
4778 continue;
4779 }
4780 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
4781 tracked.insert(
4782 entry.path,
4783 TrackedEntry {
4784 index_mode: entry.mode,
4785 index_oid: entry.oid,
4786 worktree,
4787 },
4788 );
4789 }
4790 Ok(tracked)
4791}
4792
4793fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
4794 let metadata = match std::fs::symlink_metadata(path) {
4795 Ok(metadata) => metadata,
4796 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
4797 Err(e) => {
4798 return Err(spar_err!(
4799 "could not inspect tracked file {}: {e}",
4800 path.display()
4801 ))
4802 }
4803 };
4804 let mut fingerprint = Sha256::new();
4805 if metadata.file_type().is_symlink() {
4806 let target = std::fs::read_link(path)
4807 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
4808 let bytes = os_str_bytes(target.as_os_str())?;
4809 fingerprint.update(b"symlink\0");
4810 fingerprint.update(&bytes);
4811 let content = Sha256::digest(&bytes).into();
4812 return Ok(Some(WorktreeFile {
4813 mode: "120000".to_string(),
4814 #[cfg(unix)]
4815 permissions: 0,
4816 raw_oid: git_blob_oid(oid_len, &bytes)?,
4817 fingerprint: fingerprint.finalize().into(),
4818 content,
4819 }));
4820 }
4821 if !metadata.is_file() {
4822 bail!("tracked path {} is not a file or symlink", path.display());
4823 }
4824
4825 let mut options = OpenOptions::new();
4826 options.read(true);
4827 #[cfg(unix)]
4828 {
4829 use std::os::unix::fs::OpenOptionsExt;
4830 options.custom_flags(libc::O_NOFOLLOW);
4831 }
4832 let mut file = options
4833 .open(path)
4834 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
4835 let before = file
4836 .metadata()
4837 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
4838 let mode = tracked_file_mode(&before);
4839 #[cfg(unix)]
4840 let permissions = {
4841 use std::os::unix::fs::MetadataExt;
4842 before.mode() & 0o7777
4843 };
4844 fingerprint.update(b"file\0");
4845 fingerprint.update(mode.as_bytes());
4846 #[cfg(unix)]
4847 fingerprint.update(permissions.to_le_bytes());
4848 fingerprint.update(before.len().to_le_bytes());
4849 let mut content = Sha256::new();
4850 let header = format!("blob {}\0", before.len());
4851 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
4852 let mut buf = [0u8; 64 * 1024];
4853 loop {
4854 let read = file
4855 .read(&mut buf)
4856 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
4857 if read == 0 {
4858 break;
4859 }
4860 fingerprint.update(&buf[..read]);
4861 content.update(&buf[..read]);
4862 object.update(&buf[..read]);
4863 }
4864 let after = file
4865 .metadata()
4866 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4867 if before.len() != after.len()
4868 || before.modified().ok() != after.modified().ok()
4869 || before.permissions() != after.permissions()
4870 {
4871 bail!(
4872 "tracked file {} changed while it was being inspected",
4873 path.display()
4874 );
4875 }
4876 let current = std::fs::symlink_metadata(path)
4877 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4878 if !same_file(&after, ¤t) {
4879 bail!(
4880 "tracked file {} was replaced while it was being inspected",
4881 path.display()
4882 );
4883 }
4884 Ok(Some(WorktreeFile {
4885 mode,
4886 #[cfg(unix)]
4887 permissions,
4888 raw_oid: object.finish(),
4889 fingerprint: fingerprint.finalize().into(),
4890 content: content.finalize().into(),
4891 }))
4892}
4893
4894fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
4895 let metadata = std::fs::symlink_metadata(path)
4896 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
4897 let mut digest = Sha256::new();
4898 if metadata.file_type().is_symlink() {
4899 digest.update(b"symlink\0");
4900 let target = std::fs::read_link(path)
4901 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
4902 digest.update(os_str_bytes(target.as_os_str())?);
4903 return Ok(digest.finalize().into());
4904 }
4905 if !metadata.is_file() {
4906 bail!("attribute path {} is not a file or symlink", path.display());
4907 }
4908 let mut options = OpenOptions::new();
4909 options.read(true);
4910 #[cfg(unix)]
4911 {
4912 use std::os::unix::fs::OpenOptionsExt;
4913 options.custom_flags(libc::O_NOFOLLOW);
4914 }
4915 let mut file = options
4916 .open(path)
4917 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
4918 let before = file
4919 .metadata()
4920 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
4921 digest.update(b"file\0");
4922 let mut buf = [0u8; 64 * 1024];
4923 loop {
4924 let read = file
4925 .read(&mut buf)
4926 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
4927 if read == 0 {
4928 break;
4929 }
4930 digest.update(&buf[..read]);
4931 }
4932 let after = file
4933 .metadata()
4934 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
4935 let current = std::fs::symlink_metadata(path)
4936 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
4937 if before.len() != after.len()
4938 || before.modified().ok() != after.modified().ok()
4939 || !same_file(&after, ¤t)
4940 {
4941 bail!(
4942 "attribute file {} changed while it was being inspected",
4943 path.display()
4944 );
4945 }
4946 Ok(digest.finalize().into())
4947}
4948
4949enum ObjectHasher {
4950 Sha1(Sha1),
4951 Sha256(Sha256),
4952}
4953
4954impl ObjectHasher {
4955 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
4956 let mut hasher = match oid_len {
4957 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
4958 64 => Self::Sha256(Sha256::new()),
4959 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
4960 };
4961 hasher.update(header);
4962 Ok(hasher)
4963 }
4964
4965 fn update(&mut self, bytes: &[u8]) {
4966 match self {
4967 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
4968 Self::Sha256(hasher) => hasher.update(bytes),
4969 }
4970 }
4971
4972 fn finish(self) -> String {
4973 let bytes = match self {
4974 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
4975 Self::Sha256(hasher) => hasher.finalize().to_vec(),
4976 };
4977 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
4978 }
4979}
4980
4981fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
4982 let header = format!("blob {}\0", bytes.len());
4983 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
4984 hasher.update(bytes);
4985 Ok(hasher.finish())
4986}
4987
4988fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
4989 let mut first = open_regular_file(path)?;
4990 let first_before = first
4991 .metadata()
4992 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
4993 let mut raw_len = 0u64;
4994 let mut crlf_pairs = 0u64;
4995 let mut previous_was_cr = false;
4996 let mut every_lf_was_crlf = true;
4997 let mut buf = [0u8; 64 * 1024];
4998 loop {
4999 let read = first
5000 .read(&mut buf)
5001 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5002 if read == 0 {
5003 break;
5004 }
5005 raw_len = raw_len
5006 .checked_add(read as u64)
5007 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5008 for byte in &buf[..read] {
5009 if *byte == b'\n' {
5010 if previous_was_cr {
5011 crlf_pairs += 1;
5012 } else {
5013 every_lf_was_crlf = false;
5014 }
5015 }
5016 previous_was_cr = *byte == b'\r';
5017 }
5018 }
5019 let first_after = first
5020 .metadata()
5021 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5022 let current = std::fs::symlink_metadata(path)
5023 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5024 if raw_len != first_before.len()
5025 || !stable_file_metadata(&first_before, &first_after)
5026 || !stable_file_metadata(&first_after, ¤t)
5027 {
5028 bail!(
5029 "tracked file {} changed while line endings were inspected",
5030 path.display()
5031 );
5032 }
5033
5034 let normalized_len = raw_len
5035 .checked_sub(crlf_pairs)
5036 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5037 let header = format!("blob {normalized_len}\0");
5038 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5039 let mut second = open_regular_file(path)?;
5040 let second_before = second
5041 .metadata()
5042 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5043 if !stable_file_metadata(&first_after, &second_before) {
5044 bail!(
5045 "tracked file {} changed between line-ending checks",
5046 path.display()
5047 );
5048 }
5049 let mut pending_cr = false;
5050 loop {
5051 let read = second
5052 .read(&mut buf)
5053 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5054 if read == 0 {
5055 break;
5056 }
5057 for byte in &buf[..read] {
5058 if pending_cr {
5059 if *byte == b'\n' {
5060 object.update(b"\n");
5061 pending_cr = false;
5062 continue;
5063 }
5064 object.update(b"\r");
5065 pending_cr = false;
5066 }
5067 if *byte == b'\r' {
5068 pending_cr = true;
5069 } else {
5070 object.update(std::slice::from_ref(byte));
5071 }
5072 }
5073 }
5074 if pending_cr {
5075 object.update(b"\r");
5076 }
5077 let second_after = second
5078 .metadata()
5079 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5080 let current = std::fs::symlink_metadata(path)
5081 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5082 if !stable_file_metadata(&second_before, &second_after)
5083 || !stable_file_metadata(&second_after, ¤t)
5084 {
5085 bail!(
5086 "tracked file {} changed while line endings were hashed",
5087 path.display()
5088 );
5089 }
5090 Ok((object.finish(), every_lf_was_crlf))
5091}
5092
5093fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5094 let mut options = OpenOptions::new();
5095 options.read(true);
5096 #[cfg(unix)]
5097 {
5098 use std::os::unix::fs::OpenOptionsExt;
5099 options.custom_flags(libc::O_NOFOLLOW);
5100 }
5101 let file = options
5102 .open(path)
5103 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5104 let metadata = file
5105 .metadata()
5106 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5107 if !metadata.is_file() {
5108 bail!("tracked path {} is not a regular file", path.display());
5109 }
5110 Ok(file)
5111}
5112
5113fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5114 if !same_file(left, right)
5115 || left.len() != right.len()
5116 || left.modified().ok() != right.modified().ok()
5117 || left.permissions() != right.permissions()
5118 {
5119 return false;
5120 }
5121 #[cfg(unix)]
5122 {
5123 use std::os::unix::fs::MetadataExt;
5124 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5125 }
5126 #[cfg(not(unix))]
5127 {
5128 left.created().ok() == right.created().ok()
5129 }
5130}
5131
5132fn check_attributes(
5133 cwd: &Path,
5134 paths: impl IntoIterator<Item = PathBuf>,
5135) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5136 const NAMES: [&str; 6] = [
5137 "filter",
5138 "working-tree-encoding",
5139 "ident",
5140 "text",
5141 "eol",
5142 "crlf",
5143 ];
5144 let paths = paths.into_iter().collect::<BTreeSet<_>>();
5145 if paths.is_empty() {
5146 return Ok(BTreeMap::new());
5147 }
5148 let mut input = String::new();
5149 for path in &paths {
5150 let path = path.to_str().ok_or_else(|| {
5151 spar_err!(
5152 "cannot inspect attributes for a non-UTF-8 path in {}",
5153 cwd.display()
5154 )
5155 })?;
5156 input.push_str(path);
5157 input.push('\0');
5158 }
5159 let argv = git_without_automation_argv(&[
5160 "check-attr",
5161 "-z",
5162 "--cached",
5163 "--stdin",
5164 "filter",
5165 "working-tree-encoding",
5166 "ident",
5167 "text",
5168 "eol",
5169 "crlf",
5170 ]);
5171 let output = proc::run_bytes(
5172 &argv,
5173 &ExecOpts::new()
5174 .cwd(cwd)
5175 .timeout_secs(30)
5176 .stdin(input)
5177 .stop_descendants(true),
5178 )?;
5179 if !output.is_empty() && !output.ends_with(&[0]) {
5180 bail!(
5181 "git returned an unterminated attribute result for {}",
5182 cwd.display()
5183 );
5184 }
5185 let fields = output
5186 .split(|byte| *byte == 0)
5187 .filter(|field| !field.is_empty())
5188 .collect::<Vec<_>>();
5189 if fields.len() != paths.len() * NAMES.len() * 3 {
5190 bail!(
5191 "git returned an unexpected attribute result for {}",
5192 cwd.display()
5193 );
5194 }
5195 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5196 for record in fields.chunks_exact(3) {
5197 let path = safe_git_path(record[0], "attribute")?;
5198 if !paths.contains(&path) {
5199 bail!(
5200 "git returned attributes for the wrong path in {}",
5201 cwd.display()
5202 );
5203 }
5204 let name = std::str::from_utf8(record[1])
5205 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
5206 let value = std::str::from_utf8(record[2])
5207 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
5208 values
5209 .entry(path)
5210 .or_default()
5211 .insert(name.to_string(), value.to_string());
5212 }
5213 if paths.iter().any(|path| {
5214 values
5215 .get(path)
5216 .is_none_or(|attributes| attributes.len() != NAMES.len())
5217 }) {
5218 bail!(
5219 "git omitted an attribute result for a tracked path in {}",
5220 cwd.display()
5221 );
5222 }
5223 Ok(values)
5224}
5225
5226fn attribute_is_active(value: Option<&String>) -> bool {
5227 !matches!(
5228 value.map(String::as_str),
5229 None | Some("unspecified") | Some("unset")
5230 )
5231}
5232
5233fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
5234 attribute_is_active(values.get("filter"))
5235 || attribute_is_active(values.get("working-tree-encoding"))
5236}
5237
5238fn path_has_ambiguous_transform(cwd: &Path, values: &BTreeMap<String, String>) -> Result<bool> {
5239 if path_has_external_transform(values)
5240 || attribute_is_active(values.get("ident"))
5241 || attribute_is_active(values.get("crlf"))
5242 {
5243 return Ok(true);
5244 }
5245 let text = values.get("text").map(String::as_str);
5246 let eol = values.get("eol").map(String::as_str);
5247 if text == Some("auto") {
5248 return Ok(true);
5249 }
5250 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
5251 || !matches!(
5252 eol,
5253 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
5254 )
5255 {
5256 return Ok(true);
5257 }
5258 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
5259 return Ok(
5260 git_config_value(cwd, "core.autocrlf")?.is_some_and(|value| {
5261 matches!(
5262 value.to_ascii_lowercase().as_str(),
5263 "true" | "yes" | "on" | "1"
5264 )
5265 }),
5266 );
5267 }
5268 Ok(false)
5269}
5270
5271fn allows_expected_crlf(cwd: &Path, values: &BTreeMap<String, String>) -> Result<bool> {
5272 if path_has_external_transform(values)
5273 || attribute_is_active(values.get("ident"))
5274 || attribute_is_active(values.get("crlf"))
5275 {
5276 return Ok(false);
5277 }
5278 let text = values.get("text").map(String::as_str);
5279 let eol = values.get("eol").map(String::as_str);
5280 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
5281 return Ok(false);
5282 }
5283 if eol == Some("crlf") {
5284 return Ok(true);
5285 }
5286 if text != Some("set") {
5287 return Ok(false);
5288 }
5289 if let Some(autocrlf) = git_config_value(cwd, "core.autocrlf")? {
5290 match autocrlf.to_ascii_lowercase().as_str() {
5291 "true" | "yes" | "on" | "1" => return Ok(true),
5292 "input" => return Ok(false),
5293 _ => {}
5294 }
5295 }
5296 if git_config_value(cwd, "core.eol")?.is_some_and(|value| value.eq_ignore_ascii_case("crlf")) {
5297 return Ok(true);
5298 }
5299 #[cfg(windows)]
5300 if git_config_value(cwd, "core.eol")?.is_none_or(|value| value.eq_ignore_ascii_case("native")) {
5301 return Ok(true);
5302 }
5303 Ok(false)
5304}
5305
5306fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
5307 let argv = git_without_automation_argv(&["config", "--get", key]);
5308 let output = proc::exec(
5309 &argv,
5310 &ExecOpts::new()
5311 .cwd(cwd)
5312 .timeout_secs(30)
5313 .check(false)
5314 .stop_descendants(true),
5315 )?;
5316 match output.code {
5317 0 => Ok(Some(output.stdout.trim().to_string())),
5318 1 => Ok(None),
5319 _ => bail!(
5320 "could not read Git configuration in {}: {}",
5321 cwd.display(),
5322 output.stderr.trim()
5323 ),
5324 }
5325}
5326
5327fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
5328 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
5329 let output = proc::exec(
5330 &argv,
5331 &ExecOpts::new()
5332 .cwd(cwd)
5333 .timeout_secs(30)
5334 .check(false)
5335 .stop_descendants(true),
5336 )?;
5337 match output.code {
5338 0 if output.stdout.trim() == "true" => Ok(Some(true)),
5339 0 if output.stdout.trim() == "false" => Ok(Some(false)),
5340 0 => bail!(
5341 "git returned an invalid boolean for {key} in {}",
5342 cwd.display()
5343 ),
5344 1 => Ok(None),
5345 _ => bail!(
5346 "could not read Git configuration in {}: {}",
5347 cwd.display(),
5348 output.stderr.trim()
5349 ),
5350 }
5351}
5352
5353#[cfg(unix)]
5354fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
5355 use std::os::unix::fs::PermissionsExt;
5356 if metadata.permissions().mode() & 0o111 == 0 {
5357 "100644".to_string()
5358 } else {
5359 "100755".to_string()
5360 }
5361}
5362
5363#[cfg(not(unix))]
5364fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
5365 "100644".to_string()
5366}
5367
5368fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
5369 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
5370 if !listed.is_empty() && !listed.ends_with(&[0]) {
5371 bail!(
5372 "git returned an unterminated tree listing for {}",
5373 cwd.display()
5374 );
5375 }
5376 let mut entries = Vec::new();
5377 for record in listed
5378 .split(|byte| *byte == 0)
5379 .filter(|record| !record.is_empty())
5380 {
5381 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5382 bail!("git returned a malformed tree record for {}", cwd.display());
5383 };
5384 let fields = record[..tab]
5385 .split(|byte| *byte == b' ')
5386 .collect::<Vec<_>>();
5387 if fields.len() != 3 {
5388 bail!("git returned a malformed tree header for {}", cwd.display());
5389 }
5390 let mode = std::str::from_utf8(fields[0])
5391 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
5392 .to_string();
5393 let oid = std::str::from_utf8(fields[2])
5394 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5395 .to_string();
5396 entries.push(IndexEntry {
5397 path: safe_git_path(&record[tab + 1..], "tree")?,
5398 mode,
5399 oid,
5400 });
5401 }
5402 Ok(entries)
5403}
5404
5405fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
5406 Ok(tree_entries(cwd, "HEAD")?
5407 .into_iter()
5408 .filter(|entry| entry.mode == "160000")
5409 .map(|entry| (entry.path, entry.oid))
5410 .collect())
5411}
5412
5413fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
5414 let head = head_gitlinks(cwd)?;
5415 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
5416 .into_iter()
5417 .map(|link| (link.path, link.oid))
5418 .collect();
5419 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
5420 paths.extend(index.keys().cloned());
5421 Ok(paths
5422 .into_iter()
5423 .filter(|path| head.get(path) != index.get(path))
5424 .collect())
5425}
5426
5427fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
5428 let path = parent.join(relative);
5429 let metadata = match std::fs::symlink_metadata(&path) {
5430 Ok(metadata) => metadata,
5431 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5432 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
5433 };
5434 if !metadata.is_dir() {
5435 bail!("the gitlink at {} is not a directory", path.display());
5436 }
5437 let canonical = std::fs::canonicalize(&path)
5438 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
5439 if canonical != path {
5440 bail!(
5441 "the gitlink at {} resolves through a symlink",
5442 path.display()
5443 );
5444 }
5445 if !path.join(".git").exists() {
5446 let empty = std::fs::read_dir(&path)
5447 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
5448 .next()
5449 .is_none();
5450 if empty {
5451 return Ok(None);
5452 }
5453 bail!(
5454 "the uninitialized gitlink at {} contains local files",
5455 path.display()
5456 );
5457 }
5458 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
5459 if inside.trim() != "true" {
5460 bail!("the gitlink at {} is not a worktree", path.display());
5461 }
5462 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
5463 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
5464 spar_err!(
5465 "could not resolve the gitlink top level at {}: {e}",
5466 path.display()
5467 )
5468 })?;
5469 if top != canonical {
5470 bail!(
5471 "the gitlink at {} belongs to a different worktree",
5472 path.display()
5473 );
5474 }
5475 Ok(Some(canonical))
5476}
5477
5478fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
5479 let root = std::fs::canonicalize(cwd)
5480 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5481 let mut allowed = BTreeSet::from([root.join(".git")]);
5482 let mut repositories = vec![root.clone()];
5483 let mut visited = BTreeSet::new();
5484 while let Some(repository) = repositories.pop() {
5485 let canonical = std::fs::canonicalize(&repository)
5486 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5487 if !visited.insert(canonical.clone()) {
5488 bail!("submodule recursion revisited {}", canonical.display());
5489 }
5490 for link in gitlinks(&canonical)? {
5491 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
5492 continue;
5493 };
5494 allowed.insert(submodule.join(".git"));
5495 repositories.push(submodule);
5496 }
5497 }
5498
5499 let scan_root = root.clone();
5500 let mut directories = vec![root];
5501 while let Some(directory) = directories.pop() {
5502 let entries = std::fs::read_dir(&directory)
5503 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5504 for entry in entries {
5505 let entry =
5506 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5507 let path = entry.path();
5508 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
5509 continue;
5510 }
5511 if entry.file_name() == OsStr::new(".git") {
5512 if !allowed.contains(&path) {
5513 return Ok(Some(path));
5514 }
5515 continue;
5516 }
5517 let kind = entry
5518 .file_type()
5519 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5520 if kind.is_dir() {
5521 directories.push(path);
5522 }
5523 }
5524 }
5525 Ok(None)
5526}
5527
5528pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
5529 if let Some(path) = unexpected_nested_git_entry(cwd)? {
5530 bail!(
5531 "the worktree contains an untracked Git entry at {}. It was kept because its \
5532 repository objects are not represented by the outer index.",
5533 path.display()
5534 );
5535 }
5536 let root = std::fs::canonicalize(cwd)
5537 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5538 let mut repositories = BTreeMap::new();
5539 let mut visited = BTreeSet::new();
5540 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
5541 Ok(GitState { repositories })
5542}
5543
5544fn collect_git_state(
5545 repository: &Path,
5546 prefix: &Path,
5547 visited: &mut BTreeSet<PathBuf>,
5548 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
5549) -> Result<()> {
5550 let canonical = std::fs::canonicalize(repository)
5551 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5552 if !visited.insert(canonical.clone()) {
5553 bail!("submodule recursion revisited {}", canonical.display());
5554 }
5555 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5556 let head = head.trim().to_string();
5557 if head.is_empty() {
5558 bail!("git returned an empty head for {}", repository.display());
5559 }
5560 let unsafe_index_flags = unsafe_index_flags(repository)?;
5561 let tracked = tracked_entries(repository)?;
5562 let gitlinks = gitlinks(repository)?;
5563 if repositories
5564 .insert(
5565 prefix.to_path_buf(),
5566 RepositoryState {
5567 head,
5568 unsafe_index_flags,
5569 tracked,
5570 gitlinks: gitlinks
5571 .iter()
5572 .map(|link| (link.path.clone(), link.oid.clone()))
5573 .collect(),
5574 },
5575 )
5576 .is_some()
5577 {
5578 bail!("Git state contains duplicate repository path {:?}", prefix);
5579 }
5580
5581 for link in gitlinks {
5582 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5583 continue;
5584 };
5585 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
5586 }
5587 Ok(())
5588}
5589
5590fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
5591 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
5592 if !listed.is_empty() && !listed.ends_with(&[0]) {
5593 bail!(
5594 "git returned an unterminated index-flag listing for {}",
5595 cwd.display()
5596 );
5597 }
5598 let mut unsafe_records = Vec::new();
5599 for record in listed
5600 .split(|byte| *byte == 0)
5601 .filter(|record| !record.is_empty())
5602 {
5603 if record.len() < 3 || record[1] != b' ' {
5604 bail!(
5605 "git returned a malformed index-flag record for {}",
5606 cwd.display()
5607 );
5608 }
5609 if record[0] != b'H' {
5610 unsafe_records.extend_from_slice(record);
5611 unsafe_records.push(0);
5612 }
5613 }
5614 Ok(unsafe_records)
5615}
5616
5617pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
5618 safe_git_state(cwd).map(|_| ())
5619}
5620
5621pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
5622 let state = git_state(cwd)?;
5623 if let Some((path, _repository)) = state
5624 .repositories
5625 .iter()
5626 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
5627 {
5628 let label = if path.as_os_str().is_empty() {
5629 cwd.to_path_buf()
5630 } else {
5631 cwd.join(path)
5632 };
5633 bail!(
5634 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
5635 SPAR cannot prove the working files are unchanged, so it was kept.",
5636 label.display()
5637 );
5638 }
5639 Ok(state)
5640}
5641
5642fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
5643 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
5644 return Ok(true);
5645 }
5646 let mut visited = BTreeSet::new();
5647 repository_has_recoverable_work_inner(cwd, include_ignored, &mut visited)
5648}
5649
5650fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
5651 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
5652 let git_dir = PathBuf::from(git_dir.trim());
5653 let git_dir = if git_dir.is_absolute() {
5654 git_dir
5655 } else {
5656 cwd.join(git_dir)
5657 };
5658 let git_dir = std::fs::canonicalize(&git_dir)
5659 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
5660 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
5661 Ok(_) => return Ok(true),
5662 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
5663 Err(error) => {
5664 return Err(spar_err!(
5665 "could not inspect per-worktree configuration in {}: {error}",
5666 git_dir.display()
5667 ))
5668 }
5669 }
5670
5671 let orig_head = git_dir.join("ORIG_HEAD");
5672 match std::fs::symlink_metadata(&orig_head) {
5673 Ok(metadata) if metadata.is_file() => {
5674 let oid = std::fs::read_to_string(&orig_head)
5675 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
5676 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
5677 return Ok(true);
5678 };
5679 if !commit_has_shared_ref(cwd, &commit)? {
5680 return Ok(true);
5681 }
5682 }
5683 Ok(_) => return Ok(true),
5684 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
5685 Err(error) => {
5686 return Err(spar_err!(
5687 "could not inspect {}: {error}",
5688 orig_head.display()
5689 ))
5690 }
5691 }
5692
5693 let edit_message = git_dir.join("COMMIT_EDITMSG");
5694 match std::fs::symlink_metadata(&edit_message) {
5695 Ok(metadata) if metadata.is_file() => {
5696 let draft = std::fs::read(&edit_message)
5697 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
5698 if draft != head_commit_message(cwd)? {
5699 return Ok(true);
5700 }
5701 }
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 {}: {error}",
5707 edit_message.display()
5708 ))
5709 }
5710 }
5711
5712 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
5713 return Ok(true);
5714 }
5715
5716 let local_refs = run_git_bytes(
5717 cwd,
5718 &[
5719 "for-each-ref",
5720 "--format=%(refname)",
5721 "refs/worktree",
5722 "refs/bisect",
5723 "refs/rewritten",
5724 ],
5725 )?;
5726 if !local_refs.is_empty() {
5727 return Ok(true);
5728 }
5729
5730 for entry in std::fs::read_dir(&git_dir)
5731 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
5732 {
5733 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
5734 let known = matches!(
5735 entry.file_name().to_str(),
5736 Some(
5737 "HEAD"
5738 | "ORIG_HEAD"
5739 | "COMMIT_EDITMSG"
5740 | "commondir"
5741 | "gitdir"
5742 | "index"
5743 | "logs"
5744 | "refs"
5745 )
5746 );
5747 if !known {
5748 return Ok(true);
5749 }
5750 }
5751
5752 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5753 if !commit_has_shared_ref(cwd, head.trim())? {
5754 return Ok(true);
5755 }
5756 Ok(false)
5757}
5758
5759fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
5760 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
5761 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
5762 bail!(
5763 "git returned a commit without a message separator in {}",
5764 cwd.display()
5765 );
5766 };
5767 Ok(commit[split + 2..].to_vec())
5768}
5769
5770fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
5771 let metadata = match std::fs::symlink_metadata(logs) {
5772 Ok(metadata) => metadata,
5773 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5774 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
5775 };
5776 if !metadata.is_dir() {
5777 return Ok(true);
5778 }
5779 let mut files = Vec::new();
5780 let mut directories = vec![logs.to_path_buf()];
5781 while let Some(directory) = directories.pop() {
5782 for entry in std::fs::read_dir(&directory)
5783 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
5784 {
5785 let entry =
5786 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5787 let path = entry.path();
5788 let kind = entry
5789 .file_type()
5790 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5791 if kind.is_dir() {
5792 directories.push(path);
5793 } else if kind.is_file() {
5794 files.push(path);
5795 } else {
5796 return Ok(true);
5797 }
5798 }
5799 }
5800
5801 let mut commits = BTreeSet::new();
5802 for path in files {
5803 if !collect_reflog_commits(cwd, &path, &mut commits)? {
5804 return Ok(true);
5805 }
5806 }
5807 for commit in commits {
5808 if !commit_has_shared_ref(cwd, &commit)? {
5809 return Ok(true);
5810 }
5811 }
5812 Ok(false)
5813}
5814
5815fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
5819 let common = common_git_dir(cwd)?;
5820 let reflog = common.join("logs").join(refname);
5821 let metadata = match std::fs::symlink_metadata(&reflog) {
5822 Ok(metadata) => metadata,
5823 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
5824 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
5825 };
5826 if !metadata.is_file() {
5827 return Ok(false);
5828 }
5829 let mut commits = BTreeSet::new();
5830 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
5831 return Ok(false);
5832 }
5833 for commit in commits {
5834 if is_ancestor(cwd, &commit, durable_tip)?
5835 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
5836 {
5837 continue;
5838 }
5839 return Ok(false);
5840 }
5841 Ok(true)
5842}
5843
5844fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
5845 let file = std::fs::File::open(path)
5846 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
5847 for line in std::io::BufReader::new(file).lines() {
5848 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
5849 let mut fields = line.splitn(3, ' ');
5850 let Some(old) = fields.next() else {
5851 return Ok(false);
5852 };
5853 let Some(new) = fields.next() else {
5854 return Ok(false);
5855 };
5856 if fields.next().is_none() {
5857 return Ok(false);
5858 }
5859 for oid in [old, new] {
5860 if oid.bytes().all(|byte| byte == b'0') {
5861 continue;
5862 }
5863 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
5864 return Ok(false);
5865 };
5866 commits.insert(commit);
5867 }
5868 }
5869 Ok(true)
5870}
5871
5872fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
5873 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
5874 let path = PathBuf::from(raw.trim());
5875 let path = if path.is_absolute() {
5876 path
5877 } else {
5878 cwd.join(path)
5879 };
5880 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
5881}
5882
5883fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
5884 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
5885 let output = proc::exec(
5886 &argv,
5887 &ExecOpts::new()
5888 .cwd(cwd)
5889 .timeout_secs(30)
5890 .check(false)
5891 .stop_descendants(true),
5892 )?;
5893 match output.code {
5894 0 => Ok(true),
5895 1 => Ok(false),
5896 _ => bail!("{}", proc::failure_message(&argv, &output)),
5897 }
5898}
5899
5900fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
5901 let commit = format!("{oid}^{{commit}}");
5902 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
5903 let output = proc::exec(
5904 &argv,
5905 &ExecOpts::new()
5906 .cwd(cwd)
5907 .timeout_secs(30)
5908 .check(false)
5909 .stop_descendants(true),
5910 )?;
5911 if output.code != 0 {
5912 return Ok(None);
5913 }
5914 let oid = output.stdout.trim();
5915 if oid.is_empty() {
5916 return Ok(None);
5917 }
5918 Ok(Some(oid.to_string()))
5919}
5920
5921fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
5922 commit_has_shared_ref_except(cwd, oid, None)
5923}
5924
5925fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
5926 let contains = format!("--contains={oid}");
5927 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
5928 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
5929 !record.is_empty()
5930 && !record.starts_with(b"refs/worktree/")
5931 && !record.starts_with(b"refs/bisect/")
5932 && !record.starts_with(b"refs/rewritten/")
5933 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
5934 }))
5935}
5936
5937fn has_untracked_work_worth_keeping(cwd: &Path) -> Result<bool> {
5952 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
5953 if !ordinary.is_empty() {
5954 return Ok(true);
5955 }
5956 let listed = untracked_listing(
5957 cwd,
5958 &[
5959 "ls-files",
5960 "--others",
5961 "--ignored",
5962 "--exclude-standard",
5963 "-z",
5964 ],
5965 )?;
5966 for raw in listed {
5967 let (path, nested) = untracked_record(&raw, "ignored")?;
5968 if nested || !is_generated_artifact(&path) {
5969 return Ok(true);
5970 }
5971 }
5972 Ok(false)
5973}
5974
5975fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
5976 let listed = run_git_bytes(cwd, args)?;
5977 if !listed.is_empty() && !listed.ends_with(&[0]) {
5978 bail!(
5979 "git returned an unterminated untracked-file list for {}",
5980 cwd.display()
5981 );
5982 }
5983 Ok(listed
5984 .split(|byte| *byte == 0)
5985 .filter(|raw| !raw.is_empty())
5986 .map(|raw| raw.to_vec())
5987 .collect())
5988}
5989
5990fn repository_has_recoverable_work_inner(
5991 cwd: &Path,
5992 include_ignored: bool,
5993 visited: &mut BTreeSet<PathBuf>,
5994) -> Result<bool> {
5995 let canonical = std::fs::canonicalize(cwd)
5996 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5997 if !visited.insert(canonical.clone()) {
5998 bail!("submodule recursion revisited {}", canonical.display());
5999 }
6000 if include_ignored && has_untracked_work_worth_keeping(cwd)? {
6001 return Ok(true);
6002 }
6003 if !unsafe_index_flags(cwd)?.is_empty() {
6004 return Ok(true);
6005 }
6006 if attributes_may_be_modified(cwd)? {
6007 return Ok(true);
6008 }
6009 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6010 return Ok(true);
6011 }
6012 if include_ignored {
6013 let index = index_entries(cwd)?
6014 .into_iter()
6015 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6016 .collect::<BTreeMap<_, _>>();
6017 let head = tree_entries(cwd, "HEAD")?
6018 .into_iter()
6019 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6020 .collect::<BTreeMap<_, _>>();
6021 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6022 return Ok(true);
6023 }
6024 let tracked = tracked_entries(cwd)?;
6025 let effective = check_attributes(cwd, tracked.keys().cloned())?;
6026 for (path, entry) in tracked {
6027 let Some(worktree) = entry.worktree else {
6028 return Ok(true);
6029 };
6030 let attributes = effective.get(&path).ok_or_else(|| {
6031 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6032 })?;
6033 if path_has_ambiguous_transform(cwd, attributes)? {
6034 return Ok(true);
6035 }
6036 let symlink_file = entry.index_mode == "120000"
6037 && worktree.mode == "100644"
6038 && worktree.raw_oid == entry.index_oid
6039 && git_config_bool(cwd, "core.symlinks")? == Some(false);
6040 if worktree.mode != entry.index_mode && !symlink_file {
6041 return Ok(true);
6042 }
6043 if entry.index_mode == "120000" {
6044 if worktree.raw_oid != entry.index_oid {
6045 return Ok(true);
6046 }
6047 continue;
6048 }
6049 #[cfg(unix)]
6050 {
6051 let expected = if entry.index_mode == "100755" {
6052 0o755
6053 } else {
6054 0o644
6055 };
6056 if worktree.permissions != expected {
6057 return Ok(true);
6058 }
6059 }
6060 if allows_expected_crlf(cwd, attributes)? {
6061 let (normalized, every_lf_was_crlf) =
6062 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6063 if !every_lf_was_crlf || normalized != entry.index_oid {
6064 return Ok(true);
6065 }
6066 } else if worktree.raw_oid != entry.index_oid {
6067 return Ok(true);
6068 }
6069 }
6070 } else {
6071 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6072 if !run_git_bytes(cwd, &args)?.is_empty() {
6073 return Ok(true);
6074 }
6075 }
6076 for link in gitlinks(cwd)? {
6077 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6078 continue;
6079 };
6080 if include_ignored {
6085 return Ok(true);
6086 }
6087 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6088 if head.trim() != link.oid {
6089 return Ok(true);
6090 }
6091 if repository_has_recoverable_work_inner(&submodule, include_ignored, visited)? {
6092 return Ok(true);
6093 }
6094 }
6095 Ok(false)
6096}
6097
6098pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6099 repository_has_recoverable_work(cwd, false)
6100}
6101
6102fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6103 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6104 Ok(!run_git_bytes(cwd, &args)?.is_empty())
6105}
6106
6107#[cfg(unix)]
6108fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6109 use std::os::unix::ffi::OsStringExt;
6110 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6111}
6112
6113#[cfg(not(unix))]
6114fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6115 String::from_utf8(raw.to_vec())
6116 .map(PathBuf::from)
6117 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6118}
6119
6120fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
6129 let metadata = std::fs::symlink_metadata(path).map_err(|e| {
6130 spar_err!(
6131 "could not inspect the nested repository at {}: {e}",
6132 path.display()
6133 )
6134 })?;
6135 if !metadata.is_dir() {
6136 bail!(
6137 "git reported {} as a nested repository, but it is not a directory",
6138 path.display()
6139 );
6140 }
6141 if std::fs::symlink_metadata(path.join(".git")).is_err() {
6142 bail!(
6143 "git reported {} as a nested repository, but it has no Git entry",
6144 path.display()
6145 );
6146 }
6147 #[cfg(unix)]
6148 {
6149 use std::os::unix::fs::MetadataExt;
6150 Ok(UntrackedFile {
6151 kind: 3,
6152 len: 0,
6153 modified: None,
6154 created: metadata.created().ok(),
6155 readonly: metadata.permissions().readonly(),
6156 symlink_target: None,
6157 device: metadata.dev(),
6158 inode: metadata.ino(),
6159 mode: metadata.mode(),
6160 change_seconds: 0,
6161 change_nanoseconds: 0,
6162 })
6163 }
6164 #[cfg(not(unix))]
6165 {
6166 Ok(UntrackedFile {
6167 kind: 3,
6168 len: 0,
6169 modified: None,
6170 created: metadata.created().ok(),
6171 readonly: metadata.permissions().readonly(),
6172 symlink_target: None,
6173 })
6174 }
6175}
6176
6177fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
6178 let metadata = std::fs::symlink_metadata(path)
6179 .map_err(|e| spar_err!("could not inspect untracked file {}: {e}", path.display()))?;
6180 let kind = if metadata.file_type().is_symlink() {
6181 2
6182 } else if metadata.is_file() {
6183 1
6184 } else {
6185 bail!(
6186 "untracked path {} is not a regular file or symlink",
6187 path.display()
6188 );
6189 };
6190 let symlink_target = if kind == 2 {
6191 let target = std::fs::read_link(path)
6192 .map_err(|e| spar_err!("could not read untracked symlink {}: {e}", path.display()))?;
6193 Some(os_str_bytes(target.as_os_str())?)
6194 } else {
6195 None
6196 };
6197 #[cfg(unix)]
6198 {
6199 use std::os::unix::fs::MetadataExt;
6200 Ok(UntrackedFile {
6201 kind,
6202 len: metadata.len(),
6203 modified: metadata.modified().ok(),
6204 created: metadata.created().ok(),
6205 readonly: metadata.permissions().readonly(),
6206 symlink_target,
6207 device: metadata.dev(),
6208 inode: metadata.ino(),
6209 mode: metadata.mode(),
6210 change_seconds: metadata.ctime(),
6211 change_nanoseconds: metadata.ctime_nsec(),
6212 })
6213 }
6214 #[cfg(not(unix))]
6215 {
6216 Ok(UntrackedFile {
6217 kind,
6218 len: metadata.len(),
6219 modified: metadata.modified().ok(),
6220 created: metadata.created().ok(),
6221 readonly: metadata.permissions().readonly(),
6222 symlink_target,
6223 })
6224 }
6225}
6226
6227#[cfg(unix)]
6228fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6229 use std::os::unix::ffi::OsStrExt;
6230 Ok(value.as_bytes().to_vec())
6231}
6232
6233#[cfg(not(unix))]
6234fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6235 value
6236 .to_str()
6237 .map(|value| value.as_bytes().to_vec())
6238 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
6239}
6240
6241#[cfg(unix)]
6242fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6243 use std::os::unix::fs::MetadataExt;
6244 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
6245}
6246
6247#[cfg(not(unix))]
6248fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6249 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
6250}
6251
6252#[derive(Debug, Clone, serde::Serialize, Deserialize)]
6253pub struct BranchRecord {
6254 pub kind: String,
6255 pub number: i64,
6256}
6257
6258pub fn review_ref(number: i64) -> String {
6261 format!("refs/spar/pr-{number}")
6262}
6263
6264pub fn is_finished(state: &str) -> bool {
6265 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
6266}
6267
6268pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
6275 if let Some(parent) = path.parent() {
6276 std::fs::create_dir_all(parent)
6277 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6278 }
6279 let tmp = path.with_extension(format!(
6282 "{}.tmp",
6283 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
6284 ));
6285 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
6286 std::fs::rename(&tmp, path)
6287 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
6288 Ok(())
6289}
6290
6291pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
6294 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
6295}
6296
6297pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
6304 #[derive(Deserialize)]
6305 #[serde(rename_all = "camelCase")]
6306 struct Row {
6307 number: i64,
6308 #[serde(default)]
6309 url: String,
6310 #[serde(default)]
6311 title: String,
6312 #[serde(default)]
6313 closing_issues_references: Vec<IssueRef>,
6314 }
6315
6316 serde_json::from_str::<Vec<Row>>(json.trim())
6317 .ok()?
6318 .into_iter()
6319 .find(|row| {
6320 row.closing_issues_references
6321 .iter()
6322 .any(|linked| linked.number == issue)
6323 })
6324 .map(|row| PrRef {
6325 number: row.number,
6326 url: row.url,
6327 title: row.title,
6328 })
6329}
6330
6331fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
6338 if text.trim().is_empty() {
6339 return Err(spar_err!("GitHub returned no comment data"));
6340 }
6341 let mut out = Vec::new();
6342 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6343 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
6344 Value::Array(items) => out.extend(items),
6345 _ => return Err(spar_err!("unexpected non-array comment page")),
6346 }
6347 }
6348 Ok(out)
6349}
6350
6351pub fn parse_comment_pages(text: &str) -> Vec<Value> {
6352 let mut out = Vec::new();
6353 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6354 match value {
6355 Ok(Value::Array(items)) => out.extend(items),
6356 Ok(other) => out.push(other),
6357 Err(_) => break,
6358 }
6359 }
6360 out
6361}
6362
6363pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
6366 let marker = body.find(STATE_MARKER)?;
6367 let start = body[marker..].find('{')? + marker;
6368 let end = body.rfind('}')?;
6369 if end <= start {
6370 return None;
6371 }
6372 match serde_json::from_str(&body[start..=end]) {
6373 Ok(state) => Some(state),
6374 Err(_) => {
6375 logdim!("found a spar state comment but could not parse it");
6376 None
6377 }
6378 }
6379}
6380
6381fn choose_state_for_head(
6382 candidates: Vec<PersistedState>,
6383 actual_head: &str,
6384) -> Option<PersistedState> {
6385 let matching: Vec<PersistedState> = candidates
6386 .iter()
6387 .filter(|state| state.pr_head == actual_head)
6388 .cloned()
6389 .collect();
6390 if !matching.is_empty() {
6391 return newest_state(matching);
6392 }
6393 newest_state(candidates)
6394}
6395
6396fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
6397 candidates.into_iter().reduce(|best, candidate| {
6398 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
6399 candidate
6400 } else {
6401 best
6406 }
6407 })
6408}
6409
6410pub fn self_binary() -> Result<PathBuf> {
6416 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
6417 let path = PathBuf::from(path);
6418 if proc::is_executable(&path) {
6419 return Ok(path);
6420 }
6421 bail!(
6422 "SPAR_SELF_BIN is set to {}, which is not executable",
6423 path.display()
6424 );
6425 }
6426 std::env::current_exe()
6427 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
6428}
6429
6430fn bool_env(value: bool) -> &'static str {
6431 if value {
6432 "1"
6433 } else {
6434 "0"
6435 }
6436}
6437
6438pub fn sh_quote(text: &str) -> String {
6441 format!("'{}'", text.replace('\'', r"'\''"))
6442}
6443
6444pub fn style_from_env() -> Style {
6447 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
6448 Style {
6449 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
6450 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
6451 ..Style::permissive()
6452 }
6453}
6454
6455#[cfg(test)]
6456mod tests {
6457 use super::*;
6458 use crate::config::StateStore;
6459 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
6460 use std::process::Command;
6461
6462 fn repo_for_titles() -> Repo {
6463 Repo {
6464 root: PathBuf::from("/nonexistent"),
6465 style: Style::default(),
6466 branch_prefix: String::new(),
6467 state_store: StateStore::Local,
6468 followups: crate::config::Followups::Issues,
6469 drafts: Drafts::Never,
6470 viewer: OnceLock::new(),
6471 checkpoints: Mutex::new(BTreeMap::new()),
6472 writes: WriteStats::default(),
6473 }
6474 }
6475
6476 #[test]
6477 fn write_results_accumulate_for_the_run() {
6478 let repo = repo_for_titles();
6479
6480 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
6481 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
6482
6483 assert_eq!(
6484 WriteSummary {
6485 attempted: 2,
6486 failed: 1,
6487 },
6488 repo.write_summary()
6489 );
6490 }
6491
6492 #[test]
6493 fn only_failed_write_preflights_join_the_summary() {
6494 let repo = repo_for_titles();
6495
6496 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
6497 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
6498
6499 assert_eq!(
6500 WriteSummary {
6501 attempted: 1,
6502 failed: 1,
6503 },
6504 repo.write_summary()
6505 );
6506 }
6507
6508 #[test]
6509 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
6510 let repo = repo_for_titles();
6511
6512 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
6513 assert_eq!(
6514 WriteSummary {
6515 attempted: 1,
6516 failed: 1,
6517 },
6518 repo.write_summary()
6519 );
6520 }
6521
6522 #[test]
6523 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
6524 let mut repo = repo_for_titles();
6525 repo.followups = Followups::Local;
6526
6527 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
6528 assert_eq!(WriteSummary::default(), repo.write_summary());
6529 }
6530
6531 #[test]
6532 fn a_failed_remote_state_read_stops_before_state_mutation() {
6533 let root = std::env::temp_dir().join(format!(
6534 "spar-state-preflight-{}-{}",
6535 std::process::id(),
6536 std::time::SystemTime::now()
6537 .duration_since(std::time::UNIX_EPOCH)
6538 .unwrap()
6539 .as_nanos()
6540 ));
6541 std::fs::create_dir_all(&root).unwrap();
6542 let _fixture = ReviewFixture { root: root.clone() };
6543 let mut repo = repo_for_titles();
6544 repo.root = root;
6545 repo.state_store = StateStore::Both;
6546 let state = PersistedState {
6547 version: 1,
6548 checkpoint: 4,
6549 round: 2,
6550 next_actor: "a".into(),
6551 status: Status::Pending,
6552 pr_head: "abc123".into(),
6553 ledger: Ledger::new(),
6554 filed: Vec::new(),
6555 open_findings: Vec::new(),
6556 disputes: Vec::new(),
6557 noted: Vec::new(),
6558 };
6559
6560 let error = repo
6561 .write_state_after_remote_read(
6562 7,
6563 &state,
6564 Err(crate::error::SparError::new("state comments unavailable")),
6565 )
6566 .unwrap_err();
6567
6568 assert!(error.to_string().contains("state comments unavailable"));
6569 assert!(!repo.state_path(7).exists());
6570 assert_eq!(0, repo.remembered_checkpoint(7));
6571 assert_eq!(
6572 WriteSummary {
6573 attempted: 1,
6574 failed: 1,
6575 },
6576 repo.write_summary()
6577 );
6578 }
6579
6580 #[test]
6581 fn only_known_build_and_cache_directories_are_generated_artifacts() {
6582 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
6583 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
6584 assert!(is_generated_artifact(Path::new(
6585 "package/node_modules/dependency/file.js"
6586 )));
6587 assert!(!is_generated_artifact(Path::new(
6588 "distribution/required-package.js"
6589 )));
6590 assert!(!is_generated_artifact(Path::new(
6591 "generated/required-fixture.txt"
6592 )));
6593 assert!(!is_generated_artifact(Path::new("local.env")));
6594 }
6595
6596 struct ReviewFixture {
6597 root: PathBuf,
6598 }
6599
6600 impl Drop for ReviewFixture {
6601 fn drop(&mut self) {
6602 let _ = std::fs::remove_dir_all(&self.root);
6603 }
6604 }
6605
6606 fn test_git(cwd: &Path, args: &[&str]) -> String {
6607 let output = Command::new("git")
6608 .args(args)
6609 .current_dir(cwd)
6610 .output()
6611 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
6612 assert!(
6613 output.status.success(),
6614 "git {args:?} failed: {}",
6615 String::from_utf8_lossy(&output.stderr)
6616 );
6617 String::from_utf8_lossy(&output.stdout).into_owned()
6618 }
6619
6620 fn review_fixture(
6621 tag: &str,
6622 number: i64,
6623 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
6624 use std::sync::atomic::{AtomicU32, Ordering};
6625 static NEXT: AtomicU32 = AtomicU32::new(0);
6626 let id = NEXT.fetch_add(1, Ordering::Relaxed);
6627 let root =
6628 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
6629 let origin = root.join("origin.git");
6630 let work = root.join("work");
6631 std::fs::create_dir_all(&origin).unwrap();
6632 std::fs::create_dir_all(&work).unwrap();
6633 test_git(&origin, &["init", "--bare", "-b", "main"]);
6634 test_git(&work, &["init", "-b", "main"]);
6635 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
6636 test_git(&work, &["config", "user.name", "spar test"]);
6637 test_git(&work, &["config", "commit.gpgsign", "false"]);
6638 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
6639 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
6640 std::fs::write(work.join("README.md"), "seed\n").unwrap();
6641 std::fs::write(work.join("data.txt"), "old\n").unwrap();
6642 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
6643 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
6644 test_git(&work, &["add", "."]);
6645 test_git(&work, &["commit", "-m", "seed"]);
6646 test_git(
6647 &work,
6648 &["remote", "add", "origin", origin.to_str().unwrap()],
6649 );
6650 test_git(&work, &["push", "-u", "origin", "main"]);
6651 test_git(
6652 &work,
6653 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
6654 );
6655 let cfg = crate::config::parse(
6656 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
6657 )
6658 .unwrap();
6659 let repo = Repo::open(&work, &cfg).unwrap();
6660 let path = repo.worktree_for_pr_head(number).unwrap();
6661 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6662 (ReviewFixture { root }, repo, path, checkpoint)
6663 }
6664
6665 #[test]
6666 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
6667 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
6668
6669 repo.release_review_worktree_checked(901, &checkpoint)
6670 .unwrap();
6671
6672 assert!(!path.exists());
6673 }
6674
6675 #[test]
6676 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
6677 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
6678 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
6679 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
6680 test_git(&path, &["add", "recovery.txt"]);
6681 test_git(&path, &["commit", "-m", "recovery commit"]);
6682 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
6683 test_git(&path, &["reset", "--hard", "main"]);
6684
6685 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
6686 test_git(
6687 &path,
6688 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
6689 );
6690 }
6691
6692 #[test]
6693 fn a_review_ref_reflog_only_commit_prevents_deletion() {
6694 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
6695 let local_ref = review_ref(921);
6696 let original = test_git(&path, &["rev-parse", &local_ref]);
6697 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
6698 let recovery = test_git(
6699 &path,
6700 &[
6701 "commit-tree",
6702 tree.trim(),
6703 "-p",
6704 original.trim(),
6705 "-m",
6706 "review ref recovery",
6707 ],
6708 );
6709 test_git(
6710 &path,
6711 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
6712 );
6713 test_git(
6714 &path,
6715 &["update-ref", &local_ref, original.trim(), recovery.trim()],
6716 );
6717
6718 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
6719 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
6720 test_git(
6721 &path,
6722 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
6723 );
6724 }
6725
6726 #[test]
6727 fn an_unpublished_commit_message_draft_is_recoverable() {
6728 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
6729 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
6730 let git_dir = if raw.is_absolute() {
6731 raw
6732 } else {
6733 path.join(raw)
6734 };
6735 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
6736
6737 assert!(repository_has_recoverable_work(&path, true).unwrap());
6738 assert_eq!(
6739 "unique recovery draft\n",
6740 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
6741 );
6742 }
6743
6744 #[test]
6745 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
6746 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
6747 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
6748
6749 let error = repo
6750 .release_review_worktree_checked(902, &checkpoint)
6751 .unwrap_err();
6752
6753 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6754 assert!(error.to_string().contains("kept for recovery"), "{error}");
6755 assert_eq!(
6756 "recover me\n",
6757 std::fs::read_to_string(path.join("README.md")).unwrap()
6758 );
6759 repo.release_review_worktree(902);
6760 }
6761
6762 #[test]
6763 fn a_review_commit_is_retained_after_a_checked_read() {
6764 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
6765 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
6766 test_git(&path, &["add", "review-note.txt"]);
6767 test_git(&path, &["commit", "-m", "local review recovery"]);
6768 let head = test_git(&path, &["rev-parse", "HEAD"]);
6769
6770 let error = repo
6771 .release_review_worktree_checked(903, &checkpoint)
6772 .unwrap_err();
6773
6774 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6775 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
6776 assert_eq!(
6777 "recover me\n",
6778 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
6779 );
6780 repo.release_review_worktree(903);
6781 }
6782
6783 #[test]
6784 fn an_ignored_review_file_is_retained_after_a_checked_read() {
6785 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
6786 std::fs::create_dir_all(path.join("generated")).unwrap();
6787 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
6788
6789 let error = repo
6790 .release_review_worktree_checked(904, &checkpoint)
6791 .unwrap_err();
6792
6793 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6794 assert_eq!(
6795 "recover me\n",
6796 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
6797 );
6798 repo.release_review_worktree(904);
6799 }
6800
6801 #[test]
6802 fn a_preexisting_ignored_review_file_change_is_retained() {
6803 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
6804 std::fs::create_dir_all(path.join("generated")).unwrap();
6805 let ignored = path.join("generated/recovery.txt");
6806 std::fs::write(&ignored, "before\n").unwrap();
6807 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6808 std::fs::write(&ignored, "after!\n").unwrap();
6809
6810 let error = repo
6811 .release_review_worktree_checked(905, &checkpoint)
6812 .unwrap_err();
6813
6814 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6815 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
6816 repo.release_review_worktree(905);
6817 }
6818
6819 #[test]
6820 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
6821 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
6822 std::fs::create_dir_all(path.join("generated")).unwrap();
6823 let ignored = path.join("generated/recovery.txt");
6824 std::fs::write(&ignored, "keep me\n").unwrap();
6825 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6826
6827 let error = repo
6828 .release_review_worktree_checked(906, &checkpoint)
6829 .unwrap_err();
6830
6831 assert!(error.to_string().contains("recoverable"), "{error}");
6832 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
6833 }
6834
6835 #[test]
6836 fn overwriting_a_preexisting_untracked_file_is_detected() {
6837 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
6838 let untracked = path.join("notes.txt");
6839 std::fs::write(&untracked, "before\n").unwrap();
6840 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6841 std::fs::write(&untracked, "after!\n").unwrap();
6842
6843 let error = repo
6844 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6845 .unwrap_err();
6846
6847 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6848 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
6849 }
6850
6851 #[test]
6852 fn an_assume_unchanged_edit_is_detected() {
6853 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
6854 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
6855 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
6856
6857 let error = repo
6858 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6859 .unwrap_err();
6860
6861 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6862 assert_eq!(
6863 "hidden\n",
6864 std::fs::read_to_string(path.join("README.md")).unwrap()
6865 );
6866 }
6867
6868 #[test]
6869 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
6870 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
6871 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
6872 test_git(&path, &["add", "README.md"]);
6873 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
6874
6875 let error = repo
6876 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6877 .unwrap_err();
6878
6879 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6880 assert_eq!(
6881 b"seed\r\n",
6882 std::fs::read(path.join("README.md")).unwrap().as_slice()
6883 );
6884 }
6885
6886 #[cfg(unix)]
6887 #[test]
6888 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
6889 use std::os::unix::fs::PermissionsExt;
6890
6891 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
6892 test_git(&path, &["config", "core.filemode", "false"]);
6893 let readme = path.join("README.md");
6894 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
6895 permissions.set_mode(0o755);
6896 std::fs::set_permissions(&readme, permissions).unwrap();
6897 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
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 0o755,
6906 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
6907 );
6908 }
6909
6910 #[test]
6911 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
6912 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
6913 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
6914 test_git(&path, &["add", ".gitattributes"]);
6915 test_git(&path, &["commit", "-m", "select data filter"]);
6916 let baseline = repo.worktree_baseline(&path).unwrap();
6917 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
6918
6919 assert!(repo
6920 .commit_pending_changes(&path, &baseline, "change data", "change data")
6921 .unwrap());
6922 let error = repo
6923 .refuse_unrepresented_tracked_changes(&path, &baseline)
6924 .unwrap_err();
6925
6926 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6927 assert_eq!(
6928 "secret: recover me\nnew\n",
6929 std::fs::read_to_string(path.join("data.txt")).unwrap()
6930 );
6931 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
6932 }
6933
6934 #[test]
6935 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
6936 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
6937 std::fs::create_dir_all(path.join("target")).unwrap();
6938 let untracked = path.join("target/user.yaml");
6939 std::fs::write(&untracked, "user data\n").unwrap();
6940 let baseline = repo.worktree_baseline(&path).unwrap();
6941 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
6942
6943 assert!(repo
6944 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
6945 .unwrap());
6946
6947 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
6948 assert_eq!(
6949 "?? target/user.yaml\n",
6950 test_git(&path, &["status", "--short", "--untracked-files=all"])
6951 );
6952 assert!(test_git(
6953 &path,
6954 &[
6955 "ls-tree",
6956 "-r",
6957 "--name-only",
6958 "HEAD",
6959 "--",
6960 "target/user.yaml"
6961 ]
6962 )
6963 .is_empty());
6964 }
6965
6966 #[test]
6967 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
6968 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
6969 std::fs::create_dir_all(path.join("target")).unwrap();
6970 let untracked = path.join("target/user.yaml");
6971 std::fs::write(&untracked, "before\n").unwrap();
6972 let baseline = repo.worktree_baseline(&path).unwrap();
6973 let before = test_git(&path, &["rev-parse", "HEAD"]);
6974 std::fs::write(&untracked, "after\n").unwrap();
6975 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
6976
6977 let error = repo
6978 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
6979 .unwrap_err();
6980
6981 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6982 assert!(error.to_string().contains("target/user.yaml"), "{error}");
6983 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
6984 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
6985 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
6986 }
6987
6988 #[test]
6989 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
6990 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
6991 let baseline = repo.worktree_baseline(&path).unwrap();
6992 std::fs::create_dir_all(path.join("target")).unwrap();
6993 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
6994
6995 assert!(repo
6996 .commit_pending_changes(&path, &baseline, "add file", "add file")
6997 .unwrap());
6998
6999 assert_eq!(
7000 "new file\n",
7001 test_git(&path, &["show", "HEAD:target/new.txt"])
7002 );
7003 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7004 }
7005
7006 #[test]
7007 fn deleting_existing_ignored_work_stops_a_managed_commit() {
7008 let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7009 std::fs::create_dir_all(path.join("generated")).unwrap();
7010 let ignored = path.join("generated/keep.txt");
7011 std::fs::write(&ignored, "user data\n").unwrap();
7012 let baseline = repo.worktree_baseline(&path).unwrap();
7013 let before = test_git(&path, &["rev-parse", "HEAD"]);
7014 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7015 std::fs::remove_file(&ignored).unwrap();
7016
7017 let error = repo
7018 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7019 .unwrap_err();
7020
7021 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7022 assert!(error.to_string().contains("existing untracked"), "{error}");
7023 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7024 assert_eq!(
7025 "tracked change\n",
7026 std::fs::read_to_string(path.join("README.md")).unwrap()
7027 );
7028 }
7029
7030 #[test]
7031 fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
7032 let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
7033 let baseline = repo.worktree_baseline(&path).unwrap();
7034 let before = test_git(&path, &["rev-parse", "HEAD"]);
7035 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7036 std::fs::create_dir_all(path.join("generated")).unwrap();
7037 let ignored = path.join("generated/recovery.txt");
7038 std::fs::write(&ignored, "keep me\n").unwrap();
7039
7040 let error = repo
7041 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7042 .unwrap_err();
7043
7044 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7045 assert!(error.to_string().contains("recovery.txt"), "{error}");
7046 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7047 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7048 assert!(test_git(&path, &["status", "--porcelain"])
7049 .lines()
7050 .any(|line| line == "M README.md"));
7051 }
7052
7053 #[test]
7054 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
7055 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
7056 test_git(&path, &["config", "core.autocrlf", "true"]);
7057 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7058 assert_eq!(
7059 test_git(&path, &["hash-object", "README.md"]).trim(),
7060 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7061 );
7062
7063 assert!(repository_has_recoverable_work(&path, true).unwrap());
7064 assert_eq!(
7065 "seed\n",
7066 std::fs::read_to_string(path.join("README.md")).unwrap()
7067 );
7068 }
7069
7070 #[test]
7071 fn autocrlf_input_overrides_a_crlf_core_eol() {
7072 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
7073 test_git(&path, &["config", "core.autocrlf", "input"]);
7074 test_git(&path, &["config", "core.eol", "crlf"]);
7075 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7076 assert_eq!(
7077 test_git(&path, &["hash-object", "README.md"]).trim(),
7078 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7079 );
7080
7081 assert!(repository_has_recoverable_work(&path, true).unwrap());
7082 assert_eq!(
7083 b"seed\r\n",
7084 std::fs::read(path.join("README.md")).unwrap().as_slice()
7085 );
7086 }
7087
7088 #[cfg(unix)]
7089 #[test]
7090 fn a_non_executable_permission_change_is_recoverable() {
7091 use std::os::unix::fs::PermissionsExt;
7092
7093 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
7094 let readme = path.join("README.md");
7095 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7096 permissions.set_mode(0o600);
7097 std::fs::set_permissions(&readme, permissions).unwrap();
7098 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7099
7100 let error = repo
7101 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7102 .unwrap_err();
7103
7104 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7105 assert!(repository_has_recoverable_work(&path, true).unwrap());
7106 assert_eq!(
7107 0o600,
7108 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7109 );
7110 }
7111
7112 #[cfg(unix)]
7113 #[test]
7114 fn a_managed_commit_skips_signing_and_hooks() {
7115 use std::os::unix::fs::PermissionsExt;
7116
7117 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
7118 let common = common_git_dir(&path).unwrap();
7119 let hook = common.join("hooks/pre-commit");
7120 let marker = fixture.root.join("hook-ran");
7121 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
7122 std::fs::write(
7123 &hook,
7124 format!(
7125 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
7126 sh_quote(marker.to_str().unwrap())
7127 ),
7128 )
7129 .unwrap();
7130 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
7131 permissions.set_mode(0o755);
7132 std::fs::set_permissions(&hook, permissions).unwrap();
7133 test_git(&path, &["config", "commit.gpgsign", "true"]);
7134 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
7135 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
7136 test_git(&path, &["add", "managed.txt"]);
7137
7138 repo.commit_staged_changes(&path, "record managed change")
7139 .unwrap();
7140
7141 assert!(!marker.exists());
7142 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
7143 }
7144
7145 #[test]
7146 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
7147 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
7148 std::fs::write(
7149 path.join(".gitattributes"),
7150 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
7151 )
7152 .unwrap();
7153 test_git(&path, &["add", ".gitattributes"]);
7154 test_git(&path, &["commit", "-m", "select automatic text"]);
7155 test_git(&path, &["config", "core.autocrlf", "true"]);
7156
7157 assert!(repository_has_recoverable_work(&path, true).unwrap());
7158 }
7159
7160 #[test]
7161 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
7162 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
7163 std::fs::write(
7164 path.join(".gitattributes"),
7165 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
7166 )
7167 .unwrap();
7168 test_git(&path, &["add", ".gitattributes"]);
7169 test_git(&path, &["commit", "-m", "select ident expansion"]);
7170 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7171
7172 assert!(repository_has_recoverable_work(&path, true).unwrap());
7173 }
7174
7175 fn exclude_paths(repo: &Repo, lines: &[&str]) {
7178 use std::io::Write;
7179 let path = repo.root().join(".git").join("info").join("exclude");
7180 let mut file = std::fs::OpenOptions::new()
7181 .create(true)
7182 .append(true)
7183 .open(&path)
7184 .unwrap();
7185 for line in lines {
7186 writeln!(file, "{line}").unwrap();
7187 }
7188 }
7189
7190 #[test]
7191 fn a_read_only_inspection_may_rebuild_generated_output() {
7192 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
7193 exclude_paths(&repo, &["dist/"]);
7194 std::fs::create_dir_all(path.join("dist")).unwrap();
7195 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7196 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7197 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7198 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7199
7200 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7201 .unwrap();
7202 }
7203
7204 #[test]
7205 fn a_read_only_inspection_may_not_change_an_ignored_file_elsewhere() {
7206 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
7207 exclude_paths(&repo, &["dist/", ".env.local"]);
7208 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
7209 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7210 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
7211
7212 let error = repo
7213 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7214 .unwrap_err();
7215
7216 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7217 }
7218
7219 #[test]
7220 fn build_output_alone_does_not_keep_a_worktree() {
7221 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
7222 exclude_paths(&repo, &["target/", "dist/"]);
7223 std::fs::create_dir_all(path.join("target/debug")).unwrap();
7224 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7225 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
7226 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
7227
7228 assert!(!repository_has_recoverable_work(&path, true).unwrap());
7229 repo.release_review_worktree(933);
7230
7231 assert!(!path.exists());
7232 }
7233
7234 #[test]
7235 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
7236 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
7237 exclude_paths(&repo, &["target/", ".env.local"]);
7238 std::fs::create_dir_all(path.join("target/debug")).unwrap();
7239 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7240 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
7241
7242 assert!(repository_has_recoverable_work(&path, true).unwrap());
7243 repo.release_review_worktree(934);
7244
7245 assert_eq!(
7246 "TOKEN=keep me\n",
7247 std::fs::read_to_string(path.join(".env.local")).unwrap()
7248 );
7249 }
7250
7251 #[test]
7252 fn a_repository_nested_in_build_output_keeps_a_worktree() {
7253 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
7254 exclude_paths(&repo, &["node_modules/"]);
7255 let nested = path.join("node_modules/local-dep");
7256 std::fs::create_dir_all(&nested).unwrap();
7257 test_git(&nested, &["init"]);
7258 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
7259
7260 assert!(repository_has_recoverable_work(&path, true).unwrap());
7261 repo.release_review_worktree(935);
7262
7263 assert!(nested.join(".git").exists());
7264 }
7265
7266 #[test]
7267 fn an_ordinary_untracked_file_keeps_a_worktree() {
7268 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
7269 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
7270
7271 assert!(repository_has_recoverable_work(&path, true).unwrap());
7272 repo.release_review_worktree(936);
7273
7274 assert_eq!(
7275 "somebody's notes\n",
7276 std::fs::read_to_string(path.join("notes.md")).unwrap()
7277 );
7278 }
7279
7280 #[test]
7281 fn a_legacy_crlf_checkout_is_retained_conservatively() {
7282 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
7283 std::fs::write(
7284 path.join(".gitattributes"),
7285 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
7286 )
7287 .unwrap();
7288 test_git(&path, &["add", ".gitattributes"]);
7289 test_git(&path, &["commit", "-m", "select legacy line endings"]);
7290
7291 assert!(repository_has_recoverable_work(&path, true).unwrap());
7292 }
7293
7294 #[test]
7295 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
7296 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
7297 let nested = path.join("tracked");
7298 std::fs::create_dir_all(&nested).unwrap();
7299 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
7300 test_git(&path, &["add", "tracked/seed.txt"]);
7301 test_git(&path, &["commit", "-m", "add tracked directory"]);
7302 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7303 test_git(&nested, &["init"]);
7304
7305 let error = repo
7306 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7307 .unwrap_err();
7308
7309 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7310 assert!(error.to_string().contains("Git entry"), "{error}");
7311 assert!(nested.join(".git").exists());
7312 }
7313
7314 #[test]
7315 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
7316 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
7317
7318 let state = ignored_untracked_state(repo.root()).unwrap();
7319
7320 let relative = path.strip_prefix(repo.root()).unwrap();
7321 assert!(
7322 state.files.contains_key(relative),
7323 "{:?}",
7324 state.files.keys().collect::<Vec<_>>()
7325 );
7326 assert!(state.is_ignored(relative));
7327 }
7328
7329 #[test]
7330 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
7331 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
7332 let baseline = repo.worktree_baseline(repo.root()).unwrap();
7333 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
7334 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
7335
7336 repo.refuse_new_ignored_files(repo.root(), &baseline)
7337 .unwrap();
7338 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
7339 .unwrap();
7340 }
7341
7342 #[test]
7343 fn deleting_a_resident_worktree_during_a_call_is_refused() {
7344 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
7345 let baseline = repo.worktree_baseline(repo.root()).unwrap();
7346 std::fs::remove_dir_all(&path).unwrap();
7347
7348 let error = repo
7349 .refuse_new_ignored_files(repo.root(), &baseline)
7350 .unwrap_err();
7351
7352 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7353 assert!(error.to_string().contains("review-932"), "{error}");
7354 }
7355
7356 #[test]
7357 fn a_nested_repository_record_is_read_as_a_plain_path() {
7358 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
7359 assert_eq!(Path::new("vendor/checkout"), path);
7360 assert!(nested);
7361
7362 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
7363 assert_eq!(Path::new("vendor/notes.txt"), path);
7364 assert!(!nested);
7365
7366 assert!(untracked_record(b"/", "untracked").is_err());
7367 }
7368
7369 #[cfg(unix)]
7370 #[test]
7371 fn a_non_utf8_git_path_is_preserved_without_loss() {
7372 use std::os::unix::ffi::OsStrExt;
7373
7374 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
7375
7376 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
7377 }
7378
7379 #[test]
7380 fn guarded_merge_pins_the_reviewed_head() {
7381 let args = merge_pr_args("36", Some("abc123"), true);
7382 assert_eq!(
7383 vec![
7384 "pr",
7385 "merge",
7386 "36",
7387 "--squash",
7388 "--delete-branch",
7389 "--match-head-commit",
7390 "abc123"
7391 ],
7392 args
7393 );
7394 }
7395
7396 #[test]
7397 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
7398 let pr = PrRef {
7399 number: 7,
7400 url: "https://example.test/pull/7".into(),
7401 title: "part one".into(),
7402 };
7403 let result = reconcile_pr_creation(
7404 "split-34-1",
7405 Err(crate::error::SparError::new("connection lost")),
7406 Ok(Some(pr)),
7407 )
7408 .unwrap();
7409 assert_eq!(7, result.number);
7410 }
7411
7412 #[test]
7413 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
7414 let error = reconcile_pr_creation(
7415 "split-34-1",
7416 Err(crate::error::SparError::new("permission denied")),
7417 Ok(None),
7418 )
7419 .unwrap_err();
7420 assert!(error.to_string().contains("permission denied"), "{error}");
7421 }
7422
7423 #[test]
7424 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
7425 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
7426 assert!(pr_for_base(text, "split-34-2", "split-34-1")
7427 .unwrap()
7428 .is_none());
7429 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
7430 assert_eq!(7, found.number);
7431 }
7432
7433 #[test]
7434 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
7435 let result = reconcile_comment_post(
7436 34,
7437 "the summary",
7438 crate::error::SparError::new("connection lost"),
7439 Ok(vec![serde_json::json!({"body": "the summary"})]),
7440 );
7441 assert!(result.is_ok(), "{result:?}");
7442 }
7443
7444 #[test]
7445 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
7446 let error = reconcile_comment_post(
7447 34,
7448 "the summary",
7449 crate::error::SparError::new("connection lost"),
7450 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
7451 )
7452 .unwrap_err();
7453 assert_eq!("connection lost", error.to_string());
7454 }
7455
7456 #[test]
7457 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
7458 let error = reconcile_comment_post(
7459 34,
7460 "the summary",
7461 crate::error::SparError::new("connection lost"),
7462 Err(crate::error::SparError::new("comments unavailable")),
7463 )
7464 .unwrap_err();
7465 assert!(
7466 error.to_string().contains("could not be verified"),
7467 "{error}"
7468 );
7469 assert!(
7470 error.to_string().contains("comments unavailable"),
7471 "{error}"
7472 );
7473 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7474 assert!(!error.worth_retrying());
7475 }
7476
7477 #[test]
7478 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
7479 let result = reconcile_issue_edit(
7480 34,
7481 "wanted body",
7482 crate::error::SparError::new("connection lost"),
7483 Ok("wanted body".to_string()),
7484 );
7485 assert!(result.is_ok(), "{result:?}");
7486 }
7487
7488 #[test]
7489 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
7490 let error = reconcile_issue_edit(
7491 34,
7492 "wanted body",
7493 crate::error::SparError::new("connection lost"),
7494 Err(crate::error::SparError::new("issue unavailable")),
7495 )
7496 .unwrap_err();
7497 assert!(
7498 error.to_string().contains("could not be verified"),
7499 "{error}"
7500 );
7501 assert!(error.to_string().contains("issue unavailable"), "{error}");
7502 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7503 assert!(!error.worth_retrying());
7504 }
7505
7506 #[test]
7507 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
7508 let found = ExistingIssue {
7509 number: 101,
7510 url: "https://example.test/issues/101".into(),
7511 title: "child".into(),
7512 body: "body".into(),
7513 open: true,
7514 };
7515 let url = reconcile_issue_creation(
7516 "child",
7517 Err(crate::error::SparError::new("connection lost")),
7518 Ok(Some(found)),
7519 )
7520 .unwrap();
7521 assert_eq!("https://example.test/issues/101", url);
7522 }
7523
7524 #[test]
7525 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
7526 let error = reconcile_issue_creation(
7527 "child",
7528 Err(crate::error::SparError::new("permission denied")),
7529 Ok(None),
7530 )
7531 .unwrap_err();
7532 assert!(error.to_string().contains("permission denied"), "{error}");
7533 }
7534
7535 #[test]
7536 fn an_unverifiable_issue_creation_is_marked_uncertain() {
7537 let error = reconcile_issue_creation(
7538 "child",
7539 Err(crate::error::SparError::new("connection lost")),
7540 Err(crate::error::SparError::new("issues unavailable")),
7541 )
7542 .unwrap_err();
7543 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7544 assert!(!error.worth_retrying());
7545 }
7546
7547 #[test]
7548 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
7549 let result = reconcile_failed_split_push(
7550 "split-34-1",
7551 crate::error::SparError::new("connection lost"),
7552 Ok("abc123\n".into()),
7553 Ok("abc123\trefs/heads/split-34-1\n".into()),
7554 );
7555 assert!(result.is_ok(), "{result:?}");
7556 }
7557
7558 #[test]
7559 fn a_split_push_collision_is_definite_and_never_overwrites() {
7560 let error = reconcile_failed_split_push(
7561 "split-34-1",
7562 crate::error::SparError::new("lease rejected"),
7563 Ok("abc123\n".into()),
7564 Ok("def456\trefs/heads/split-34-1\n".into()),
7565 )
7566 .unwrap_err();
7567 assert!(!error.retain_worktree());
7568 assert!(
7569 error.to_string().contains("Nothing was overwritten"),
7570 "{error}"
7571 );
7572 }
7573
7574 #[test]
7575 fn an_unreadable_split_push_result_keeps_the_worktree() {
7576 let error = reconcile_failed_split_push(
7577 "split-34-1",
7578 crate::error::SparError::new("connection lost"),
7579 Ok("abc123\n".into()),
7580 Err(crate::error::SparError::new("origin unavailable")),
7581 )
7582 .unwrap_err();
7583 assert!(error.retain_worktree());
7584 assert!(error.to_string().contains("could not confirm"), "{error}");
7585 }
7586
7587 #[test]
7591 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
7592 let repo = repo_for_titles();
7593 for raw in [
7594 "Retry loop spins \u{2014} Retry-After parses to zero",
7595 "plain title",
7596 " spread over\nlines ",
7597 "\u{1F916} Generated with something",
7598 &format!("a \u{2014} {}", "very long title ".repeat(20)),
7599 &"x".repeat(300),
7600 &format!("{} \u{2014} end", "y".repeat(88)),
7601 &{
7606 let tail = "a\u{2014}b c\u{2014}d";
7607 let pad = Style::default().max_title_chars - tail.chars().count();
7608 format!("{}{tail}", "w".repeat(pad))
7609 },
7610 ] {
7611 let once = repo.clean_title(raw).unwrap();
7612 let twice = repo.clean_title(&once).unwrap();
7613 assert_eq!(once, twice, "not idempotent for {raw:?}");
7614 assert!(
7615 once.chars().count() <= repo.style.max_title_chars,
7616 "over budget: {once:?}"
7617 );
7618 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
7619 }
7620 }
7621
7622 #[test]
7623 fn a_title_with_an_em_dash_survives_as_readable_text() {
7624 let repo = repo_for_titles();
7625 assert_eq!(
7626 "Retry loop spins, Retry-After parses to zero",
7627 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
7628 .unwrap()
7629 );
7630 }
7631
7632 #[test]
7633 fn sh_quote_survives_a_quote() {
7634 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
7635 }
7636
7637 #[test]
7638 fn sh_quote_wraps_a_space() {
7639 assert_eq!(
7640 "'/Applications/My App/spar'",
7641 sh_quote("/Applications/My App/spar")
7642 );
7643 }
7644
7645 #[test]
7646 fn finished_states_are_recognised_case_insensitively() {
7647 assert!(is_finished("MERGED"));
7648 assert!(is_finished("closed"));
7649 assert!(!is_finished("OPEN"));
7650 assert!(!is_finished(""));
7651 }
7652
7653 fn state() -> PersistedState {
7654 PersistedState {
7655 version: 1,
7656 checkpoint: 0,
7657 round: 4,
7658 next_actor: "codex".into(),
7659 status: Status::Pending,
7660 pr_head: "abc123".into(),
7661 ledger: Ledger::new(),
7662 filed: vec![],
7663 open_findings: vec![Finding {
7664 severity: Severity::Blocking,
7665 title: "Unchecked error".into(),
7666 detail: "the failure is discarded".into(),
7667 file: "src/a.rs:12".into(),
7668 ..Finding::default()
7669 }],
7670 disputes: vec![Dispute {
7671 title: "Retry limit".into(),
7672 file: "src/net.rs".into(),
7673 reasoning: "the caller already bounds it".into(),
7674 }],
7675 noted: vec![Finding {
7676 severity: Severity::NonBlocking,
7677 title: "Timeout is fixed".into(),
7678 file: "src/config.rs".into(),
7679 ..Finding::default()
7680 }],
7681 }
7682 }
7683
7684 #[test]
7685 fn a_state_comment_round_trips() {
7686 let body = format!(
7687 "{STATE_MARKER}\n{}\n-->",
7688 serde_json::to_string(&state()).unwrap()
7689 );
7690 let back = parse_state_comment(&body).unwrap();
7691 assert_eq!(4, back.round);
7692 assert_eq!("codex", back.next_actor);
7693 assert_eq!("abc123", back.pr_head);
7694 assert_eq!("Unchecked error", back.open_findings[0].title);
7695 assert_eq!("src/net.rs", back.disputes[0].file);
7696 assert_eq!("Timeout is fixed", back.noted[0].title);
7697 }
7698
7699 #[test]
7700 fn old_state_without_new_lists_still_parses() {
7701 let body = format!(
7702 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
7703 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
7704 );
7705 let back = parse_state_comment(&body).expect("old state");
7706 assert!(back.open_findings.is_empty());
7707 assert!(back.disputes.is_empty());
7708 assert!(back.noted.is_empty());
7709 assert!(back.pr_head.is_empty());
7710 assert_eq!(0, back.checkpoint);
7711 }
7712
7713 #[test]
7714 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
7715 let mut local = state();
7716 local.pr_head = "old".into();
7717 local.round = 9;
7718 let mut remote = state();
7719 remote.pr_head = "current".into();
7720 remote.round = 4;
7721
7722 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
7723 assert_eq!("current", chosen.pr_head);
7724 assert_eq!(4, chosen.round);
7725 }
7726
7727 #[test]
7728 fn checkpoint_order_breaks_same_round_ties() {
7729 let mut local = state();
7730 local.pr_head = "current".into();
7731 local.round = 4;
7732 local.checkpoint = 8;
7733 let mut remote = local.clone();
7734 remote.checkpoint = 7;
7735 remote.open_findings.clear();
7736
7737 let chosen = choose_state_for_head(vec![local], "current").unwrap();
7738 assert_eq!(8, chosen.checkpoint);
7739
7740 let mut local = state();
7741 local.pr_head = "current".into();
7742 local.round = 4;
7743 local.checkpoint = 8;
7744 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
7745 assert_eq!(8, chosen.checkpoint);
7746 }
7747
7748 #[test]
7749 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
7750 let mut local = state();
7751 local.pr_head = "current".into();
7752 local.round = 4;
7753 local.open_findings.push(Finding {
7754 title: "local checkpoint".into(),
7755 ..Finding::default()
7756 });
7757 let mut remote = state();
7758 remote.pr_head = "current".into();
7759 remote.round = 4;
7760
7761 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
7762 assert_eq!(
7763 "local checkpoint",
7764 chosen.open_findings.last().unwrap().title
7765 );
7766 }
7767
7768 #[test]
7770 fn the_state_block_is_an_html_comment() {
7771 let body = format!(
7772 "{STATE_MARKER}\n{}\n-->",
7773 serde_json::to_string(&state()).unwrap()
7774 );
7775 assert!(body.starts_with("<!--"));
7776 assert!(body.trim_end().ends_with("-->"));
7777 assert!(!body[..body.find('{').unwrap()].contains("-->"));
7778 }
7779
7780 #[test]
7781 fn an_unrelated_json_block_is_not_state() {
7782 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
7783 }
7784
7785 #[test]
7786 fn a_malformed_state_comment_is_none_not_a_panic() {
7787 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
7788 }
7789
7790 #[test]
7791 fn atomic_write_leaves_no_temp_file() {
7792 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
7793 let _ = std::fs::remove_dir_all(&dir);
7794 let path = dir.join("state").join("pr-7.json");
7795 write_json_atomic(&path, &state()).unwrap();
7796 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
7797 .unwrap()
7798 .flatten()
7799 .filter_map(|e| e.file_name().to_str().map(str::to_string))
7800 .collect();
7801 assert_eq!(vec!["pr-7.json".to_string()], files);
7802 let _ = std::fs::remove_dir_all(&dir);
7803 }
7804
7805 #[test]
7806 fn atomic_write_overwrites_rather_than_accumulating() {
7807 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
7808 let _ = std::fs::remove_dir_all(&dir);
7809 let path = dir.join("pr-7.json");
7810 for round in 1..4 {
7811 let mut s = state();
7812 s.round = round;
7813 write_json_atomic(&path, &s).unwrap();
7814 }
7815 let back: PersistedState =
7816 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
7817 assert_eq!(3, back.round);
7818 let _ = std::fs::remove_dir_all(&dir);
7819 }
7820
7821 #[test]
7822 fn style_from_env_defaults_to_enforcing() {
7823 std::env::remove_var("SPAR_BAN_EM_DASH");
7824 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
7825 let style = style_from_env();
7826 assert!(style.ban_em_dash && style.ban_ai_attribution);
7827 assert!(
7828 !style.terse,
7829 "the commit filter must not truncate a commit message"
7830 );
7831 }
7832}
7833
7834#[cfg(test)]
7835mod comment_page_tests {
7836 use super::*;
7837
7838 #[test]
7839 fn a_single_merged_array_is_read() {
7840 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
7841 assert_eq!(2, pages.len());
7842 assert_eq!(Some(2), pages[1]["id"].as_i64());
7843 }
7844
7845 #[test]
7846 fn concatenated_pages_from_an_older_gh_are_read_too() {
7847 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
7848 assert_eq!(2, pages.len());
7849 }
7850
7851 #[test]
7855 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
7856 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
7857 let pages = parse_comment_pages(text);
7858 assert_eq!(2, pages.len(), "{pages:?}");
7859 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
7860 }
7861
7862 #[test]
7863 fn empty_output_is_no_comments_not_a_panic() {
7864 assert!(parse_comment_pages("").is_empty());
7865 assert!(parse_comment_pages(" ").is_empty());
7866 assert!(parse_comment_pages("[]").is_empty());
7867 }
7868
7869 #[test]
7870 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
7871 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
7872 }
7873
7874 #[test]
7875 fn a_write_postcheck_rejects_truncated_comment_pages() {
7876 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
7877 assert!(
7878 error.to_string().contains("unexpected comment pages"),
7879 "{error}"
7880 );
7881 }
7882
7883 #[test]
7884 fn a_write_postcheck_rejects_empty_or_non_array_output() {
7885 assert!(try_parse_comment_pages("").is_err());
7886 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
7887 assert!(try_parse_comment_pages("[]").is_ok());
7888 }
7889
7890 #[test]
7891 fn state_is_found_in_the_last_matching_comment() {
7892 let payload = |round: u32| {
7893 format!(
7894 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
7895 )
7896 };
7897 let text = serde_json::to_string(&serde_json::json!([
7898 {"id": 1, "body": payload(1)},
7899 {"id": 2, "body": "looks good to me"},
7900 {"id": 3, "body": payload(5)},
7901 ]))
7902 .unwrap();
7903 let pages = parse_comment_pages(&text);
7904 let last = pages
7905 .iter()
7906 .rev()
7907 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
7908 .unwrap();
7909 assert_eq!(5, last.round);
7910 }
7911}
7912
7913#[cfg(test)]
7914mod linked_pr_tests {
7915 use super::*;
7916
7917 const REAL_PAYLOAD: &str = r#"[
7922 {"number":14252,"title":"fix: reject leading-dash branch names",
7923 "url":"https://github.com/cli/cli/pull/14252",
7924 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
7925 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
7926 "url":"https://github.com/cli/cli/issues/14238"}]},
7927 {"number":14217,"title":"another change",
7928 "url":"https://github.com/cli/cli/pull/14217",
7929 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
7930 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
7931 "url":"https://github.com/cli/cli/issues/9761"}]},
7932 {"number":14200,"title":"unlinked work",
7933 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
7934 ]"#;
7935
7936 #[test]
7937 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
7938 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
7939 assert_eq!(14252, pr.number);
7940 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
7941 }
7942
7943 #[test]
7944 fn the_right_pr_is_picked_out_of_several() {
7945 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
7946 }
7947
7948 #[test]
7949 fn an_issue_nobody_is_working_on_finds_nothing() {
7950 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
7951 }
7952
7953 #[test]
7954 fn an_unlinked_pr_is_never_matched() {
7955 for issue in [14200, 0, 1] {
7957 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
7958 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
7959 }
7960 }
7961 }
7962
7963 #[test]
7964 fn empty_or_broken_output_is_none_rather_than_a_panic() {
7965 assert!(find_linked_pr("", 1).is_none());
7966 assert!(find_linked_pr("[]", 1).is_none());
7967 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
7968 assert!(find_linked_pr("[{\"number\":", 1).is_none());
7969 }
7970
7971 #[test]
7973 fn pr_view_reads_the_cross_repository_flag() {
7974 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
7975 "baseRefName":"main","state":"OPEN",
7976 "closingIssuesReferences":[],"isCrossRepository":true}"#;
7977 let pr: PrView = serde_json::from_str(json).unwrap();
7978 assert!(pr.is_cross_repository);
7979 assert!(pr.is_open());
7980
7981 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
7982 assert!(
7983 !serde_json::from_str::<PrView>(&same_repo)
7984 .unwrap()
7985 .is_cross_repository
7986 );
7987 }
7988}
7989
7990#[cfg(test)]
7991mod min_number_tests {
7992 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
7998 let mut numbers: Vec<i64> = open.to_vec();
7999 numbers.sort_unstable();
8000 if min_number > 0 {
8001 numbers.retain(|n| *n >= min_number);
8002 }
8003 numbers.truncate(limit);
8004 numbers
8005 }
8006
8007 #[test]
8008 fn the_floor_is_applied_before_the_cap_not_after() {
8009 let open = [12, 13, 14, 480, 481, 482];
8010 assert_eq!(vec![480, 481], pick(&open, 2, 480));
8011 assert!(!pick(&open, 2, 480).is_empty());
8014 }
8015
8016 #[test]
8017 fn no_floor_keeps_the_old_behaviour() {
8018 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
8019 }
8020
8021 #[test]
8022 fn the_floor_is_inclusive() {
8023 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
8024 }
8025
8026 #[test]
8027 fn a_floor_above_everything_open_yields_nothing() {
8028 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
8029 }
8030}