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 let config = CheckoutConfig::read(cwd)?;
4741 for (path, (_mode, oid)) in index {
4742 let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
4743 return Ok(true);
4744 };
4745 let attributes = effective
4746 .get(&path)
4747 .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
4748 if allows_expected_crlf(&config, attributes)? {
4749 if worktree.mode == "120000" {
4750 return Ok(true);
4751 }
4752 let (normalized, every_lf_was_crlf) =
4753 normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
4754 if !every_lf_was_crlf || normalized != oid {
4755 return Ok(true);
4756 }
4757 } else if worktree.raw_oid != oid {
4758 return Ok(true);
4759 }
4760 }
4761 Ok(false)
4762}
4763
4764fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
4765 Ok(index_entries(cwd)?
4766 .into_iter()
4767 .filter(|entry| entry.mode == "160000")
4768 .map(|entry| Gitlink {
4769 path: entry.path,
4770 oid: entry.oid,
4771 })
4772 .collect())
4773}
4774
4775fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
4776 let mut tracked = BTreeMap::new();
4777 for entry in index_entries(cwd)? {
4778 if entry.mode == "160000" {
4779 continue;
4780 }
4781 let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
4782 tracked.insert(
4783 entry.path,
4784 TrackedEntry {
4785 index_mode: entry.mode,
4786 index_oid: entry.oid,
4787 worktree,
4788 },
4789 );
4790 }
4791 Ok(tracked)
4792}
4793
4794fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
4795 let metadata = match std::fs::symlink_metadata(path) {
4796 Ok(metadata) => metadata,
4797 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
4798 Err(e) => {
4799 return Err(spar_err!(
4800 "could not inspect tracked file {}: {e}",
4801 path.display()
4802 ))
4803 }
4804 };
4805 let mut fingerprint = Sha256::new();
4806 if metadata.file_type().is_symlink() {
4807 let target = std::fs::read_link(path)
4808 .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
4809 let bytes = os_str_bytes(target.as_os_str())?;
4810 fingerprint.update(b"symlink\0");
4811 fingerprint.update(&bytes);
4812 let content = Sha256::digest(&bytes).into();
4813 return Ok(Some(WorktreeFile {
4814 mode: "120000".to_string(),
4815 #[cfg(unix)]
4816 permissions: 0,
4817 raw_oid: git_blob_oid(oid_len, &bytes)?,
4818 fingerprint: fingerprint.finalize().into(),
4819 content,
4820 }));
4821 }
4822 if !metadata.is_file() {
4823 bail!("tracked path {} is not a file or symlink", path.display());
4824 }
4825
4826 let mut options = OpenOptions::new();
4827 options.read(true);
4828 #[cfg(unix)]
4829 {
4830 use std::os::unix::fs::OpenOptionsExt;
4831 options.custom_flags(libc::O_NOFOLLOW);
4832 }
4833 let mut file = options
4834 .open(path)
4835 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
4836 let before = file
4837 .metadata()
4838 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
4839 let mode = tracked_file_mode(&before);
4840 #[cfg(unix)]
4841 let permissions = {
4842 use std::os::unix::fs::MetadataExt;
4843 before.mode() & 0o7777
4844 };
4845 fingerprint.update(b"file\0");
4846 fingerprint.update(mode.as_bytes());
4847 #[cfg(unix)]
4848 fingerprint.update(permissions.to_le_bytes());
4849 fingerprint.update(before.len().to_le_bytes());
4850 let mut content = Sha256::new();
4851 let header = format!("blob {}\0", before.len());
4852 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
4853 let mut buf = [0u8; 64 * 1024];
4854 loop {
4855 let read = file
4856 .read(&mut buf)
4857 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
4858 if read == 0 {
4859 break;
4860 }
4861 fingerprint.update(&buf[..read]);
4862 content.update(&buf[..read]);
4863 object.update(&buf[..read]);
4864 }
4865 let after = file
4866 .metadata()
4867 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4868 if before.len() != after.len()
4869 || before.modified().ok() != after.modified().ok()
4870 || before.permissions() != after.permissions()
4871 {
4872 bail!(
4873 "tracked file {} changed while it was being inspected",
4874 path.display()
4875 );
4876 }
4877 let current = std::fs::symlink_metadata(path)
4878 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
4879 if !same_file(&after, ¤t) {
4880 bail!(
4881 "tracked file {} was replaced while it was being inspected",
4882 path.display()
4883 );
4884 }
4885 Ok(Some(WorktreeFile {
4886 mode,
4887 #[cfg(unix)]
4888 permissions,
4889 raw_oid: object.finish(),
4890 fingerprint: fingerprint.finalize().into(),
4891 content: content.finalize().into(),
4892 }))
4893}
4894
4895fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
4896 let metadata = std::fs::symlink_metadata(path)
4897 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
4898 let mut digest = Sha256::new();
4899 if metadata.file_type().is_symlink() {
4900 digest.update(b"symlink\0");
4901 let target = std::fs::read_link(path)
4902 .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
4903 digest.update(os_str_bytes(target.as_os_str())?);
4904 return Ok(digest.finalize().into());
4905 }
4906 if !metadata.is_file() {
4907 bail!("attribute path {} is not a file or symlink", path.display());
4908 }
4909 let mut options = OpenOptions::new();
4910 options.read(true);
4911 #[cfg(unix)]
4912 {
4913 use std::os::unix::fs::OpenOptionsExt;
4914 options.custom_flags(libc::O_NOFOLLOW);
4915 }
4916 let mut file = options
4917 .open(path)
4918 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
4919 let before = file
4920 .metadata()
4921 .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
4922 digest.update(b"file\0");
4923 let mut buf = [0u8; 64 * 1024];
4924 loop {
4925 let read = file
4926 .read(&mut buf)
4927 .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
4928 if read == 0 {
4929 break;
4930 }
4931 digest.update(&buf[..read]);
4932 }
4933 let after = file
4934 .metadata()
4935 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
4936 let current = std::fs::symlink_metadata(path)
4937 .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
4938 if before.len() != after.len()
4939 || before.modified().ok() != after.modified().ok()
4940 || !same_file(&after, ¤t)
4941 {
4942 bail!(
4943 "attribute file {} changed while it was being inspected",
4944 path.display()
4945 );
4946 }
4947 Ok(digest.finalize().into())
4948}
4949
4950enum ObjectHasher {
4951 Sha1(Sha1),
4952 Sha256(Sha256),
4953}
4954
4955impl ObjectHasher {
4956 fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
4957 let mut hasher = match oid_len {
4958 40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
4959 64 => Self::Sha256(Sha256::new()),
4960 _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
4961 };
4962 hasher.update(header);
4963 Ok(hasher)
4964 }
4965
4966 fn update(&mut self, bytes: &[u8]) {
4967 match self {
4968 Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
4969 Self::Sha256(hasher) => hasher.update(bytes),
4970 }
4971 }
4972
4973 fn finish(self) -> String {
4974 let bytes = match self {
4975 Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
4976 Self::Sha256(hasher) => hasher.finalize().to_vec(),
4977 };
4978 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
4979 }
4980}
4981
4982fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
4983 let header = format!("blob {}\0", bytes.len());
4984 let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
4985 hasher.update(bytes);
4986 Ok(hasher.finish())
4987}
4988
4989fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
4990 let mut first = open_regular_file(path)?;
4991 let first_before = first
4992 .metadata()
4993 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
4994 let mut raw_len = 0u64;
4995 let mut crlf_pairs = 0u64;
4996 let mut previous_was_cr = false;
4997 let mut every_lf_was_crlf = true;
4998 let mut buf = [0u8; 64 * 1024];
4999 loop {
5000 let read = first
5001 .read(&mut buf)
5002 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5003 if read == 0 {
5004 break;
5005 }
5006 raw_len = raw_len
5007 .checked_add(read as u64)
5008 .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5009 for byte in &buf[..read] {
5010 if *byte == b'\n' {
5011 if previous_was_cr {
5012 crlf_pairs += 1;
5013 } else {
5014 every_lf_was_crlf = false;
5015 }
5016 }
5017 previous_was_cr = *byte == b'\r';
5018 }
5019 }
5020 let first_after = first
5021 .metadata()
5022 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5023 let current = std::fs::symlink_metadata(path)
5024 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5025 if raw_len != first_before.len()
5026 || !stable_file_metadata(&first_before, &first_after)
5027 || !stable_file_metadata(&first_after, ¤t)
5028 {
5029 bail!(
5030 "tracked file {} changed while line endings were inspected",
5031 path.display()
5032 );
5033 }
5034
5035 let normalized_len = raw_len
5036 .checked_sub(crlf_pairs)
5037 .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5038 let header = format!("blob {normalized_len}\0");
5039 let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5040 let mut second = open_regular_file(path)?;
5041 let second_before = second
5042 .metadata()
5043 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5044 if !stable_file_metadata(&first_after, &second_before) {
5045 bail!(
5046 "tracked file {} changed between line-ending checks",
5047 path.display()
5048 );
5049 }
5050 let mut pending_cr = false;
5051 loop {
5052 let read = second
5053 .read(&mut buf)
5054 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5055 if read == 0 {
5056 break;
5057 }
5058 for byte in &buf[..read] {
5059 if pending_cr {
5060 if *byte == b'\n' {
5061 object.update(b"\n");
5062 pending_cr = false;
5063 continue;
5064 }
5065 object.update(b"\r");
5066 pending_cr = false;
5067 }
5068 if *byte == b'\r' {
5069 pending_cr = true;
5070 } else {
5071 object.update(std::slice::from_ref(byte));
5072 }
5073 }
5074 }
5075 if pending_cr {
5076 object.update(b"\r");
5077 }
5078 let second_after = second
5079 .metadata()
5080 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5081 let current = std::fs::symlink_metadata(path)
5082 .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5083 if !stable_file_metadata(&second_before, &second_after)
5084 || !stable_file_metadata(&second_after, ¤t)
5085 {
5086 bail!(
5087 "tracked file {} changed while line endings were hashed",
5088 path.display()
5089 );
5090 }
5091 Ok((object.finish(), every_lf_was_crlf))
5092}
5093
5094fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5095 let mut options = OpenOptions::new();
5096 options.read(true);
5097 #[cfg(unix)]
5098 {
5099 use std::os::unix::fs::OpenOptionsExt;
5100 options.custom_flags(libc::O_NOFOLLOW);
5101 }
5102 let file = options
5103 .open(path)
5104 .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5105 let metadata = file
5106 .metadata()
5107 .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5108 if !metadata.is_file() {
5109 bail!("tracked path {} is not a regular file", path.display());
5110 }
5111 Ok(file)
5112}
5113
5114fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5115 if !same_file(left, right)
5116 || left.len() != right.len()
5117 || left.modified().ok() != right.modified().ok()
5118 || left.permissions() != right.permissions()
5119 {
5120 return false;
5121 }
5122 #[cfg(unix)]
5123 {
5124 use std::os::unix::fs::MetadataExt;
5125 left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5126 }
5127 #[cfg(not(unix))]
5128 {
5129 left.created().ok() == right.created().ok()
5130 }
5131}
5132
5133fn check_attributes(
5134 cwd: &Path,
5135 paths: impl IntoIterator<Item = PathBuf>,
5136) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5137 const NAMES: [&str; 6] = [
5138 "filter",
5139 "working-tree-encoding",
5140 "ident",
5141 "text",
5142 "eol",
5143 "crlf",
5144 ];
5145 let paths = paths.into_iter().collect::<BTreeSet<_>>();
5146 if paths.is_empty() {
5147 return Ok(BTreeMap::new());
5148 }
5149 let mut input = String::new();
5150 for path in &paths {
5151 let path = path.to_str().ok_or_else(|| {
5152 spar_err!(
5153 "cannot inspect attributes for a non-UTF-8 path in {}",
5154 cwd.display()
5155 )
5156 })?;
5157 input.push_str(path);
5158 input.push('\0');
5159 }
5160 let argv = git_without_automation_argv(&[
5161 "check-attr",
5162 "-z",
5163 "--cached",
5164 "--stdin",
5165 "filter",
5166 "working-tree-encoding",
5167 "ident",
5168 "text",
5169 "eol",
5170 "crlf",
5171 ]);
5172 let output = proc::run_bytes(
5173 &argv,
5174 &ExecOpts::new()
5175 .cwd(cwd)
5176 .timeout_secs(30)
5177 .stdin(input)
5178 .stop_descendants(true),
5179 )?;
5180 if !output.is_empty() && !output.ends_with(&[0]) {
5181 bail!(
5182 "git returned an unterminated attribute result for {}",
5183 cwd.display()
5184 );
5185 }
5186 let fields = output
5187 .split(|byte| *byte == 0)
5188 .filter(|field| !field.is_empty())
5189 .collect::<Vec<_>>();
5190 if fields.len() != paths.len() * NAMES.len() * 3 {
5191 bail!(
5192 "git returned an unexpected attribute result for {}",
5193 cwd.display()
5194 );
5195 }
5196 let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5197 for record in fields.chunks_exact(3) {
5198 let path = safe_git_path(record[0], "attribute")?;
5199 if !paths.contains(&path) {
5200 bail!(
5201 "git returned attributes for the wrong path in {}",
5202 cwd.display()
5203 );
5204 }
5205 let name = std::str::from_utf8(record[1])
5206 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
5207 let value = std::str::from_utf8(record[2])
5208 .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
5209 values
5210 .entry(path)
5211 .or_default()
5212 .insert(name.to_string(), value.to_string());
5213 }
5214 if paths.iter().any(|path| {
5215 values
5216 .get(path)
5217 .is_none_or(|attributes| attributes.len() != NAMES.len())
5218 }) {
5219 bail!(
5220 "git omitted an attribute result for a tracked path in {}",
5221 cwd.display()
5222 );
5223 }
5224 Ok(values)
5225}
5226
5227fn attribute_is_active(value: Option<&String>) -> bool {
5228 !matches!(
5229 value.map(String::as_str),
5230 None | Some("unspecified") | Some("unset")
5231 )
5232}
5233
5234fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
5235 attribute_is_active(values.get("filter"))
5236 || attribute_is_active(values.get("working-tree-encoding"))
5237}
5238
5239struct CheckoutConfig {
5248 autocrlf: Option<String>,
5249 eol: Option<String>,
5250 symlinks: Option<bool>,
5251}
5252
5253impl CheckoutConfig {
5254 fn read(cwd: &Path) -> Result<Self> {
5255 Ok(Self {
5256 autocrlf: git_config_value(cwd, "core.autocrlf")?,
5257 eol: git_config_value(cwd, "core.eol")?,
5258 symlinks: git_config_bool(cwd, "core.symlinks")?,
5259 })
5260 }
5261
5262 fn autocrlf_is_true(&self) -> bool {
5263 self.autocrlf.as_ref().is_some_and(|value| {
5264 matches!(
5265 value.to_ascii_lowercase().as_str(),
5266 "true" | "yes" | "on" | "1"
5267 )
5268 })
5269 }
5270
5271 fn eol_is(&self, wanted: &str) -> bool {
5272 self.eol
5273 .as_ref()
5274 .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
5275 }
5276}
5277
5278fn path_has_ambiguous_transform(
5279 config: &CheckoutConfig,
5280 values: &BTreeMap<String, String>,
5281) -> Result<bool> {
5282 if path_has_external_transform(values)
5283 || attribute_is_active(values.get("ident"))
5284 || attribute_is_active(values.get("crlf"))
5285 {
5286 return Ok(true);
5287 }
5288 let text = values.get("text").map(String::as_str);
5289 let eol = values.get("eol").map(String::as_str);
5290 if text == Some("auto") {
5291 return Ok(true);
5292 }
5293 if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
5294 || !matches!(
5295 eol,
5296 Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
5297 )
5298 {
5299 return Ok(true);
5300 }
5301 if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
5302 return Ok(config.autocrlf_is_true());
5303 }
5304 Ok(false)
5305}
5306
5307fn allows_expected_crlf(
5308 config: &CheckoutConfig,
5309 values: &BTreeMap<String, String>,
5310) -> Result<bool> {
5311 if path_has_external_transform(values)
5312 || attribute_is_active(values.get("ident"))
5313 || attribute_is_active(values.get("crlf"))
5314 {
5315 return Ok(false);
5316 }
5317 let text = values.get("text").map(String::as_str);
5318 let eol = values.get("eol").map(String::as_str);
5319 if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
5320 return Ok(false);
5321 }
5322 if eol == Some("crlf") {
5323 return Ok(true);
5324 }
5325 if text != Some("set") {
5326 return Ok(false);
5327 }
5328 if let Some(autocrlf) = config.autocrlf.as_deref() {
5329 match autocrlf.to_ascii_lowercase().as_str() {
5330 "true" | "yes" | "on" | "1" => return Ok(true),
5331 "input" => return Ok(false),
5332 _ => {}
5333 }
5334 }
5335 if config.eol_is("crlf") {
5336 return Ok(true);
5337 }
5338 #[cfg(windows)]
5339 if config.eol.is_none() || config.eol_is("native") {
5340 return Ok(true);
5341 }
5342 Ok(false)
5343}
5344
5345fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
5346 let argv = git_without_automation_argv(&["config", "--get", key]);
5347 let output = proc::exec(
5348 &argv,
5349 &ExecOpts::new()
5350 .cwd(cwd)
5351 .timeout_secs(30)
5352 .check(false)
5353 .stop_descendants(true),
5354 )?;
5355 match output.code {
5356 0 => Ok(Some(output.stdout.trim().to_string())),
5357 1 => Ok(None),
5358 _ => bail!(
5359 "could not read Git configuration in {}: {}",
5360 cwd.display(),
5361 output.stderr.trim()
5362 ),
5363 }
5364}
5365
5366fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
5367 let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
5368 let output = proc::exec(
5369 &argv,
5370 &ExecOpts::new()
5371 .cwd(cwd)
5372 .timeout_secs(30)
5373 .check(false)
5374 .stop_descendants(true),
5375 )?;
5376 match output.code {
5377 0 if output.stdout.trim() == "true" => Ok(Some(true)),
5378 0 if output.stdout.trim() == "false" => Ok(Some(false)),
5379 0 => bail!(
5380 "git returned an invalid boolean for {key} in {}",
5381 cwd.display()
5382 ),
5383 1 => Ok(None),
5384 _ => bail!(
5385 "could not read Git configuration in {}: {}",
5386 cwd.display(),
5387 output.stderr.trim()
5388 ),
5389 }
5390}
5391
5392#[cfg(unix)]
5393fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
5394 use std::os::unix::fs::PermissionsExt;
5395 if metadata.permissions().mode() & 0o111 == 0 {
5396 "100644".to_string()
5397 } else {
5398 "100755".to_string()
5399 }
5400}
5401
5402#[cfg(not(unix))]
5403fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
5404 "100644".to_string()
5405}
5406
5407fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
5408 let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
5409 if !listed.is_empty() && !listed.ends_with(&[0]) {
5410 bail!(
5411 "git returned an unterminated tree listing for {}",
5412 cwd.display()
5413 );
5414 }
5415 let mut entries = Vec::new();
5416 for record in listed
5417 .split(|byte| *byte == 0)
5418 .filter(|record| !record.is_empty())
5419 {
5420 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5421 bail!("git returned a malformed tree record for {}", cwd.display());
5422 };
5423 let fields = record[..tab]
5424 .split(|byte| *byte == b' ')
5425 .collect::<Vec<_>>();
5426 if fields.len() != 3 {
5427 bail!("git returned a malformed tree header for {}", cwd.display());
5428 }
5429 let mode = std::str::from_utf8(fields[0])
5430 .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
5431 .to_string();
5432 let oid = std::str::from_utf8(fields[2])
5433 .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5434 .to_string();
5435 entries.push(IndexEntry {
5436 path: safe_git_path(&record[tab + 1..], "tree")?,
5437 mode,
5438 oid,
5439 });
5440 }
5441 Ok(entries)
5442}
5443
5444fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
5445 Ok(tree_entries(cwd, "HEAD")?
5446 .into_iter()
5447 .filter(|entry| entry.mode == "160000")
5448 .map(|entry| (entry.path, entry.oid))
5449 .collect())
5450}
5451
5452fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
5453 let head = head_gitlinks(cwd)?;
5454 let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
5455 .into_iter()
5456 .map(|link| (link.path, link.oid))
5457 .collect();
5458 let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
5459 paths.extend(index.keys().cloned());
5460 Ok(paths
5461 .into_iter()
5462 .filter(|path| head.get(path) != index.get(path))
5463 .collect())
5464}
5465
5466fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
5467 let path = parent.join(relative);
5468 let metadata = match std::fs::symlink_metadata(&path) {
5469 Ok(metadata) => metadata,
5470 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5471 Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
5472 };
5473 if !metadata.is_dir() {
5474 bail!("the gitlink at {} is not a directory", path.display());
5475 }
5476 let canonical = std::fs::canonicalize(&path)
5477 .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
5478 if canonical != path {
5479 bail!(
5480 "the gitlink at {} resolves through a symlink",
5481 path.display()
5482 );
5483 }
5484 if !path.join(".git").exists() {
5485 let empty = std::fs::read_dir(&path)
5486 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
5487 .next()
5488 .is_none();
5489 if empty {
5490 return Ok(None);
5491 }
5492 bail!(
5493 "the uninitialized gitlink at {} contains local files",
5494 path.display()
5495 );
5496 }
5497 let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
5498 if inside.trim() != "true" {
5499 bail!("the gitlink at {} is not a worktree", path.display());
5500 }
5501 let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
5502 let top = std::fs::canonicalize(top.trim()).map_err(|e| {
5503 spar_err!(
5504 "could not resolve the gitlink top level at {}: {e}",
5505 path.display()
5506 )
5507 })?;
5508 if top != canonical {
5509 bail!(
5510 "the gitlink at {} belongs to a different worktree",
5511 path.display()
5512 );
5513 }
5514 Ok(Some(canonical))
5515}
5516
5517fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
5518 let root = std::fs::canonicalize(cwd)
5519 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5520 let mut allowed = BTreeSet::from([root.join(".git")]);
5521 let mut repositories = vec![root.clone()];
5522 let mut visited = BTreeSet::new();
5523 while let Some(repository) = repositories.pop() {
5524 let canonical = std::fs::canonicalize(&repository)
5525 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5526 if !visited.insert(canonical.clone()) {
5527 bail!("submodule recursion revisited {}", canonical.display());
5528 }
5529 for link in gitlinks(&canonical)? {
5530 let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
5531 continue;
5532 };
5533 allowed.insert(submodule.join(".git"));
5534 repositories.push(submodule);
5535 }
5536 }
5537
5538 let scan_root = root.clone();
5539 let mut directories = vec![root];
5540 while let Some(directory) = directories.pop() {
5541 let entries = std::fs::read_dir(&directory)
5542 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5543 for entry in entries {
5544 let entry =
5545 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5546 let path = entry.path();
5547 if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
5548 continue;
5549 }
5550 if entry.file_name() == OsStr::new(".git") {
5551 if !allowed.contains(&path) {
5552 return Ok(Some(path));
5553 }
5554 continue;
5555 }
5556 let kind = entry
5557 .file_type()
5558 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5559 if kind.is_dir() {
5560 directories.push(path);
5561 }
5562 }
5563 }
5564 Ok(None)
5565}
5566
5567pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
5568 if let Some(path) = unexpected_nested_git_entry(cwd)? {
5569 bail!(
5570 "the worktree contains an untracked Git entry at {}. It was kept because its \
5571 repository objects are not represented by the outer index.",
5572 path.display()
5573 );
5574 }
5575 let root = std::fs::canonicalize(cwd)
5576 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5577 let mut repositories = BTreeMap::new();
5578 let mut visited = BTreeSet::new();
5579 collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
5580 Ok(GitState { repositories })
5581}
5582
5583fn collect_git_state(
5584 repository: &Path,
5585 prefix: &Path,
5586 visited: &mut BTreeSet<PathBuf>,
5587 repositories: &mut BTreeMap<PathBuf, RepositoryState>,
5588) -> Result<()> {
5589 let canonical = std::fs::canonicalize(repository)
5590 .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5591 if !visited.insert(canonical.clone()) {
5592 bail!("submodule recursion revisited {}", canonical.display());
5593 }
5594 let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5595 let head = head.trim().to_string();
5596 if head.is_empty() {
5597 bail!("git returned an empty head for {}", repository.display());
5598 }
5599 let unsafe_index_flags = unsafe_index_flags(repository)?;
5600 let tracked = tracked_entries(repository)?;
5601 let gitlinks = gitlinks(repository)?;
5602 if repositories
5603 .insert(
5604 prefix.to_path_buf(),
5605 RepositoryState {
5606 head,
5607 unsafe_index_flags,
5608 tracked,
5609 gitlinks: gitlinks
5610 .iter()
5611 .map(|link| (link.path.clone(), link.oid.clone()))
5612 .collect(),
5613 },
5614 )
5615 .is_some()
5616 {
5617 bail!("Git state contains duplicate repository path {:?}", prefix);
5618 }
5619
5620 for link in gitlinks {
5621 let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5622 continue;
5623 };
5624 collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
5625 }
5626 Ok(())
5627}
5628
5629fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
5630 let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
5631 if !listed.is_empty() && !listed.ends_with(&[0]) {
5632 bail!(
5633 "git returned an unterminated index-flag listing for {}",
5634 cwd.display()
5635 );
5636 }
5637 let mut unsafe_records = Vec::new();
5638 for record in listed
5639 .split(|byte| *byte == 0)
5640 .filter(|record| !record.is_empty())
5641 {
5642 if record.len() < 3 || record[1] != b' ' {
5643 bail!(
5644 "git returned a malformed index-flag record for {}",
5645 cwd.display()
5646 );
5647 }
5648 if record[0] != b'H' {
5649 unsafe_records.extend_from_slice(record);
5650 unsafe_records.push(0);
5651 }
5652 }
5653 Ok(unsafe_records)
5654}
5655
5656pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
5657 safe_git_state(cwd).map(|_| ())
5658}
5659
5660pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
5661 let state = git_state(cwd)?;
5662 if let Some((path, _repository)) = state
5663 .repositories
5664 .iter()
5665 .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
5666 {
5667 let label = if path.as_os_str().is_empty() {
5668 cwd.to_path_buf()
5669 } else {
5670 cwd.join(path)
5671 };
5672 bail!(
5673 "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
5674 SPAR cannot prove the working files are unchanged, so it was kept.",
5675 label.display()
5676 );
5677 }
5678 Ok(state)
5679}
5680
5681fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
5682 if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
5683 return Ok(true);
5684 }
5685 let mut visited = BTreeSet::new();
5686 repository_has_recoverable_work_inner(cwd, include_ignored, &mut visited)
5687}
5688
5689fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
5690 let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
5691 let git_dir = PathBuf::from(git_dir.trim());
5692 let git_dir = if git_dir.is_absolute() {
5693 git_dir
5694 } else {
5695 cwd.join(git_dir)
5696 };
5697 let git_dir = std::fs::canonicalize(&git_dir)
5698 .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
5699 match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
5700 Ok(_) => return Ok(true),
5701 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
5702 Err(error) => {
5703 return Err(spar_err!(
5704 "could not inspect per-worktree configuration in {}: {error}",
5705 git_dir.display()
5706 ))
5707 }
5708 }
5709
5710 let orig_head = git_dir.join("ORIG_HEAD");
5711 match std::fs::symlink_metadata(&orig_head) {
5712 Ok(metadata) if metadata.is_file() => {
5713 let oid = std::fs::read_to_string(&orig_head)
5714 .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
5715 let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
5716 return Ok(true);
5717 };
5718 if !commit_has_shared_ref(cwd, &commit)? {
5719 return Ok(true);
5720 }
5721 }
5722 Ok(_) => return Ok(true),
5723 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
5724 Err(error) => {
5725 return Err(spar_err!(
5726 "could not inspect {}: {error}",
5727 orig_head.display()
5728 ))
5729 }
5730 }
5731
5732 let edit_message = git_dir.join("COMMIT_EDITMSG");
5733 match std::fs::symlink_metadata(&edit_message) {
5734 Ok(metadata) if metadata.is_file() => {
5735 let draft = std::fs::read(&edit_message)
5736 .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
5737 if draft != head_commit_message(cwd)? {
5738 return Ok(true);
5739 }
5740 }
5741 Ok(_) => return Ok(true),
5742 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
5743 Err(error) => {
5744 return Err(spar_err!(
5745 "could not inspect {}: {error}",
5746 edit_message.display()
5747 ))
5748 }
5749 }
5750
5751 if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
5752 return Ok(true);
5753 }
5754
5755 let local_refs = run_git_bytes(
5756 cwd,
5757 &[
5758 "for-each-ref",
5759 "--format=%(refname)",
5760 "refs/worktree",
5761 "refs/bisect",
5762 "refs/rewritten",
5763 ],
5764 )?;
5765 if !local_refs.is_empty() {
5766 return Ok(true);
5767 }
5768
5769 for entry in std::fs::read_dir(&git_dir)
5770 .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
5771 {
5772 let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
5773 let known = matches!(
5774 entry.file_name().to_str(),
5775 Some(
5776 "HEAD"
5777 | "ORIG_HEAD"
5778 | "COMMIT_EDITMSG"
5779 | "commondir"
5780 | "gitdir"
5781 | "index"
5782 | "logs"
5783 | "refs"
5784 )
5785 );
5786 if !known {
5787 return Ok(true);
5788 }
5789 }
5790
5791 let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5792 if !commit_has_shared_ref(cwd, head.trim())? {
5793 return Ok(true);
5794 }
5795 Ok(false)
5796}
5797
5798fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
5799 let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
5800 let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
5801 bail!(
5802 "git returned a commit without a message separator in {}",
5803 cwd.display()
5804 );
5805 };
5806 Ok(commit[split + 2..].to_vec())
5807}
5808
5809fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
5810 let metadata = match std::fs::symlink_metadata(logs) {
5811 Ok(metadata) => metadata,
5812 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5813 Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
5814 };
5815 if !metadata.is_dir() {
5816 return Ok(true);
5817 }
5818 let mut files = Vec::new();
5819 let mut directories = vec![logs.to_path_buf()];
5820 while let Some(directory) = directories.pop() {
5821 for entry in std::fs::read_dir(&directory)
5822 .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
5823 {
5824 let entry =
5825 entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5826 let path = entry.path();
5827 let kind = entry
5828 .file_type()
5829 .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5830 if kind.is_dir() {
5831 directories.push(path);
5832 } else if kind.is_file() {
5833 files.push(path);
5834 } else {
5835 return Ok(true);
5836 }
5837 }
5838 }
5839
5840 let mut commits = BTreeSet::new();
5841 for path in files {
5842 if !collect_reflog_commits(cwd, &path, &mut commits)? {
5843 return Ok(true);
5844 }
5845 }
5846 for commit in commits {
5847 if !commit_has_shared_ref(cwd, &commit)? {
5848 return Ok(true);
5849 }
5850 }
5851 Ok(false)
5852}
5853
5854fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
5858 let common = common_git_dir(cwd)?;
5859 let reflog = common.join("logs").join(refname);
5860 let metadata = match std::fs::symlink_metadata(&reflog) {
5861 Ok(metadata) => metadata,
5862 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
5863 Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
5864 };
5865 if !metadata.is_file() {
5866 return Ok(false);
5867 }
5868 let mut commits = BTreeSet::new();
5869 if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
5870 return Ok(false);
5871 }
5872 for commit in commits {
5873 if is_ancestor(cwd, &commit, durable_tip)?
5874 || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
5875 {
5876 continue;
5877 }
5878 return Ok(false);
5879 }
5880 Ok(true)
5881}
5882
5883fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
5884 let file = std::fs::File::open(path)
5885 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
5886 for line in std::io::BufReader::new(file).lines() {
5887 let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
5888 let mut fields = line.splitn(3, ' ');
5889 let Some(old) = fields.next() else {
5890 return Ok(false);
5891 };
5892 let Some(new) = fields.next() else {
5893 return Ok(false);
5894 };
5895 if fields.next().is_none() {
5896 return Ok(false);
5897 }
5898 for oid in [old, new] {
5899 if oid.bytes().all(|byte| byte == b'0') {
5900 continue;
5901 }
5902 let Some(commit) = resolve_optional_commit(cwd, oid)? else {
5903 return Ok(false);
5904 };
5905 commits.insert(commit);
5906 }
5907 }
5908 Ok(true)
5909}
5910
5911fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
5912 let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
5913 let path = PathBuf::from(raw.trim());
5914 let path = if path.is_absolute() {
5915 path
5916 } else {
5917 cwd.join(path)
5918 };
5919 std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
5920}
5921
5922fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
5923 let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
5924 let output = proc::exec(
5925 &argv,
5926 &ExecOpts::new()
5927 .cwd(cwd)
5928 .timeout_secs(30)
5929 .check(false)
5930 .stop_descendants(true),
5931 )?;
5932 match output.code {
5933 0 => Ok(true),
5934 1 => Ok(false),
5935 _ => bail!("{}", proc::failure_message(&argv, &output)),
5936 }
5937}
5938
5939fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
5940 let commit = format!("{oid}^{{commit}}");
5941 let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
5942 let output = proc::exec(
5943 &argv,
5944 &ExecOpts::new()
5945 .cwd(cwd)
5946 .timeout_secs(30)
5947 .check(false)
5948 .stop_descendants(true),
5949 )?;
5950 if output.code != 0 {
5951 return Ok(None);
5952 }
5953 let oid = output.stdout.trim();
5954 if oid.is_empty() {
5955 return Ok(None);
5956 }
5957 Ok(Some(oid.to_string()))
5958}
5959
5960fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
5961 commit_has_shared_ref_except(cwd, oid, None)
5962}
5963
5964fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
5965 let contains = format!("--contains={oid}");
5966 let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
5967 Ok(shared.split(|byte| *byte == b'\n').any(|record| {
5968 !record.is_empty()
5969 && !record.starts_with(b"refs/worktree/")
5970 && !record.starts_with(b"refs/bisect/")
5971 && !record.starts_with(b"refs/rewritten/")
5972 && exclude.is_none_or(|excluded| record != excluded.as_bytes())
5973 }))
5974}
5975
5976fn has_untracked_work_worth_keeping(cwd: &Path) -> Result<bool> {
5991 let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
5992 if !ordinary.is_empty() {
5993 return Ok(true);
5994 }
5995 let listed = untracked_listing(
5996 cwd,
5997 &[
5998 "ls-files",
5999 "--others",
6000 "--ignored",
6001 "--exclude-standard",
6002 "-z",
6003 ],
6004 )?;
6005 for raw in listed {
6006 let (path, nested) = untracked_record(&raw, "ignored")?;
6007 if nested || !is_generated_artifact(&path) {
6008 return Ok(true);
6009 }
6010 }
6011 Ok(false)
6012}
6013
6014fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6015 let listed = run_git_bytes(cwd, args)?;
6016 if !listed.is_empty() && !listed.ends_with(&[0]) {
6017 bail!(
6018 "git returned an unterminated untracked-file list for {}",
6019 cwd.display()
6020 );
6021 }
6022 Ok(listed
6023 .split(|byte| *byte == 0)
6024 .filter(|raw| !raw.is_empty())
6025 .map(|raw| raw.to_vec())
6026 .collect())
6027}
6028
6029fn repository_has_recoverable_work_inner(
6030 cwd: &Path,
6031 include_ignored: bool,
6032 visited: &mut BTreeSet<PathBuf>,
6033) -> Result<bool> {
6034 let canonical = std::fs::canonicalize(cwd)
6035 .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6036 if !visited.insert(canonical.clone()) {
6037 bail!("submodule recursion revisited {}", canonical.display());
6038 }
6039 if include_ignored && has_untracked_work_worth_keeping(cwd)? {
6040 return Ok(true);
6041 }
6042 if !unsafe_index_flags(cwd)?.is_empty() {
6043 return Ok(true);
6044 }
6045 if attributes_may_be_modified(cwd)? {
6046 return Ok(true);
6047 }
6048 if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6049 return Ok(true);
6050 }
6051 if include_ignored {
6052 let index = index_entries(cwd)?
6053 .into_iter()
6054 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6055 .collect::<BTreeMap<_, _>>();
6056 let head = tree_entries(cwd, "HEAD")?
6057 .into_iter()
6058 .map(|entry| (entry.path, (entry.mode, entry.oid)))
6059 .collect::<BTreeMap<_, _>>();
6060 if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6061 return Ok(true);
6062 }
6063 let tracked = tracked_entries(cwd)?;
6064 let effective = check_attributes(cwd, tracked.keys().cloned())?;
6065 let config = CheckoutConfig::read(cwd)?;
6066 for (path, entry) in tracked {
6067 let Some(worktree) = entry.worktree else {
6068 return Ok(true);
6069 };
6070 let attributes = effective.get(&path).ok_or_else(|| {
6071 spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6072 })?;
6073 if path_has_ambiguous_transform(&config, attributes)? {
6074 return Ok(true);
6075 }
6076 let symlink_file = entry.index_mode == "120000"
6077 && worktree.mode == "100644"
6078 && worktree.raw_oid == entry.index_oid
6079 && config.symlinks == Some(false);
6080 if worktree.mode != entry.index_mode && !symlink_file {
6081 return Ok(true);
6082 }
6083 if entry.index_mode == "120000" {
6084 if worktree.raw_oid != entry.index_oid {
6085 return Ok(true);
6086 }
6087 continue;
6088 }
6089 #[cfg(unix)]
6090 {
6091 let expected = if entry.index_mode == "100755" {
6092 0o755
6093 } else {
6094 0o644
6095 };
6096 if worktree.permissions != expected {
6097 return Ok(true);
6098 }
6099 }
6100 if allows_expected_crlf(&config, attributes)? {
6101 let (normalized, every_lf_was_crlf) =
6102 normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6103 if !every_lf_was_crlf || normalized != entry.index_oid {
6104 return Ok(true);
6105 }
6106 } else if worktree.raw_oid != entry.index_oid {
6107 return Ok(true);
6108 }
6109 }
6110 } else {
6111 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6112 if !run_git_bytes(cwd, &args)?.is_empty() {
6113 return Ok(true);
6114 }
6115 }
6116 for link in gitlinks(cwd)? {
6117 let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6118 continue;
6119 };
6120 if include_ignored {
6125 return Ok(true);
6126 }
6127 let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6128 if head.trim() != link.oid {
6129 return Ok(true);
6130 }
6131 if repository_has_recoverable_work_inner(&submodule, include_ignored, visited)? {
6132 return Ok(true);
6133 }
6134 }
6135 Ok(false)
6136}
6137
6138pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6139 repository_has_recoverable_work(cwd, false)
6140}
6141
6142fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6143 let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6144 Ok(!run_git_bytes(cwd, &args)?.is_empty())
6145}
6146
6147#[cfg(unix)]
6148fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6149 use std::os::unix::ffi::OsStringExt;
6150 Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6151}
6152
6153#[cfg(not(unix))]
6154fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6155 String::from_utf8(raw.to_vec())
6156 .map(PathBuf::from)
6157 .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6158}
6159
6160fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
6169 let metadata = std::fs::symlink_metadata(path).map_err(|e| {
6170 spar_err!(
6171 "could not inspect the nested repository at {}: {e}",
6172 path.display()
6173 )
6174 })?;
6175 if !metadata.is_dir() {
6176 bail!(
6177 "git reported {} as a nested repository, but it is not a directory",
6178 path.display()
6179 );
6180 }
6181 if std::fs::symlink_metadata(path.join(".git")).is_err() {
6182 bail!(
6183 "git reported {} as a nested repository, but it has no Git entry",
6184 path.display()
6185 );
6186 }
6187 #[cfg(unix)]
6188 {
6189 use std::os::unix::fs::MetadataExt;
6190 Ok(UntrackedFile {
6191 kind: 3,
6192 len: 0,
6193 modified: None,
6194 created: metadata.created().ok(),
6195 readonly: metadata.permissions().readonly(),
6196 symlink_target: None,
6197 device: metadata.dev(),
6198 inode: metadata.ino(),
6199 mode: metadata.mode(),
6200 change_seconds: 0,
6201 change_nanoseconds: 0,
6202 })
6203 }
6204 #[cfg(not(unix))]
6205 {
6206 Ok(UntrackedFile {
6207 kind: 3,
6208 len: 0,
6209 modified: None,
6210 created: metadata.created().ok(),
6211 readonly: metadata.permissions().readonly(),
6212 symlink_target: None,
6213 })
6214 }
6215}
6216
6217fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
6218 let metadata = std::fs::symlink_metadata(path)
6219 .map_err(|e| spar_err!("could not inspect untracked file {}: {e}", path.display()))?;
6220 let kind = if metadata.file_type().is_symlink() {
6221 2
6222 } else if metadata.is_file() {
6223 1
6224 } else {
6225 bail!(
6226 "untracked path {} is not a regular file or symlink",
6227 path.display()
6228 );
6229 };
6230 let symlink_target = if kind == 2 {
6231 let target = std::fs::read_link(path)
6232 .map_err(|e| spar_err!("could not read untracked symlink {}: {e}", path.display()))?;
6233 Some(os_str_bytes(target.as_os_str())?)
6234 } else {
6235 None
6236 };
6237 #[cfg(unix)]
6238 {
6239 use std::os::unix::fs::MetadataExt;
6240 Ok(UntrackedFile {
6241 kind,
6242 len: metadata.len(),
6243 modified: metadata.modified().ok(),
6244 created: metadata.created().ok(),
6245 readonly: metadata.permissions().readonly(),
6246 symlink_target,
6247 device: metadata.dev(),
6248 inode: metadata.ino(),
6249 mode: metadata.mode(),
6250 change_seconds: metadata.ctime(),
6251 change_nanoseconds: metadata.ctime_nsec(),
6252 })
6253 }
6254 #[cfg(not(unix))]
6255 {
6256 Ok(UntrackedFile {
6257 kind,
6258 len: metadata.len(),
6259 modified: metadata.modified().ok(),
6260 created: metadata.created().ok(),
6261 readonly: metadata.permissions().readonly(),
6262 symlink_target,
6263 })
6264 }
6265}
6266
6267#[cfg(unix)]
6268fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6269 use std::os::unix::ffi::OsStrExt;
6270 Ok(value.as_bytes().to_vec())
6271}
6272
6273#[cfg(not(unix))]
6274fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6275 value
6276 .to_str()
6277 .map(|value| value.as_bytes().to_vec())
6278 .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
6279}
6280
6281#[cfg(unix)]
6282fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6283 use std::os::unix::fs::MetadataExt;
6284 right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
6285}
6286
6287#[cfg(not(unix))]
6288fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6289 right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
6290}
6291
6292#[derive(Debug, Clone, serde::Serialize, Deserialize)]
6293pub struct BranchRecord {
6294 pub kind: String,
6295 pub number: i64,
6296}
6297
6298pub fn review_ref(number: i64) -> String {
6301 format!("refs/spar/pr-{number}")
6302}
6303
6304pub fn is_finished(state: &str) -> bool {
6305 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
6306}
6307
6308pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
6315 if let Some(parent) = path.parent() {
6316 std::fs::create_dir_all(parent)
6317 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6318 }
6319 let tmp = path.with_extension(format!(
6322 "{}.tmp",
6323 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
6324 ));
6325 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
6326 std::fs::rename(&tmp, path)
6327 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
6328 Ok(())
6329}
6330
6331pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
6334 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
6335}
6336
6337pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
6344 #[derive(Deserialize)]
6345 #[serde(rename_all = "camelCase")]
6346 struct Row {
6347 number: i64,
6348 #[serde(default)]
6349 url: String,
6350 #[serde(default)]
6351 title: String,
6352 #[serde(default)]
6353 closing_issues_references: Vec<IssueRef>,
6354 }
6355
6356 serde_json::from_str::<Vec<Row>>(json.trim())
6357 .ok()?
6358 .into_iter()
6359 .find(|row| {
6360 row.closing_issues_references
6361 .iter()
6362 .any(|linked| linked.number == issue)
6363 })
6364 .map(|row| PrRef {
6365 number: row.number,
6366 url: row.url,
6367 title: row.title,
6368 })
6369}
6370
6371fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
6378 if text.trim().is_empty() {
6379 return Err(spar_err!("GitHub returned no comment data"));
6380 }
6381 let mut out = Vec::new();
6382 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6383 match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
6384 Value::Array(items) => out.extend(items),
6385 _ => return Err(spar_err!("unexpected non-array comment page")),
6386 }
6387 }
6388 Ok(out)
6389}
6390
6391pub fn parse_comment_pages(text: &str) -> Vec<Value> {
6392 let mut out = Vec::new();
6393 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6394 match value {
6395 Ok(Value::Array(items)) => out.extend(items),
6396 Ok(other) => out.push(other),
6397 Err(_) => break,
6398 }
6399 }
6400 out
6401}
6402
6403pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
6406 let marker = body.find(STATE_MARKER)?;
6407 let start = body[marker..].find('{')? + marker;
6408 let end = body.rfind('}')?;
6409 if end <= start {
6410 return None;
6411 }
6412 match serde_json::from_str(&body[start..=end]) {
6413 Ok(state) => Some(state),
6414 Err(_) => {
6415 logdim!("found a spar state comment but could not parse it");
6416 None
6417 }
6418 }
6419}
6420
6421fn choose_state_for_head(
6422 candidates: Vec<PersistedState>,
6423 actual_head: &str,
6424) -> Option<PersistedState> {
6425 let matching: Vec<PersistedState> = candidates
6426 .iter()
6427 .filter(|state| state.pr_head == actual_head)
6428 .cloned()
6429 .collect();
6430 if !matching.is_empty() {
6431 return newest_state(matching);
6432 }
6433 newest_state(candidates)
6434}
6435
6436fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
6437 candidates.into_iter().reduce(|best, candidate| {
6438 if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
6439 candidate
6440 } else {
6441 best
6446 }
6447 })
6448}
6449
6450pub fn self_binary() -> Result<PathBuf> {
6456 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
6457 let path = PathBuf::from(path);
6458 if proc::is_executable(&path) {
6459 return Ok(path);
6460 }
6461 bail!(
6462 "SPAR_SELF_BIN is set to {}, which is not executable",
6463 path.display()
6464 );
6465 }
6466 std::env::current_exe()
6467 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
6468}
6469
6470fn bool_env(value: bool) -> &'static str {
6471 if value {
6472 "1"
6473 } else {
6474 "0"
6475 }
6476}
6477
6478pub fn sh_quote(text: &str) -> String {
6481 format!("'{}'", text.replace('\'', r"'\''"))
6482}
6483
6484pub fn style_from_env() -> Style {
6487 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
6488 Style {
6489 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
6490 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
6491 ..Style::permissive()
6492 }
6493}
6494
6495#[cfg(test)]
6496mod tests {
6497 use super::*;
6498 use crate::config::StateStore;
6499 use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
6500 use std::process::Command;
6501
6502 fn repo_for_titles() -> Repo {
6503 Repo {
6504 root: PathBuf::from("/nonexistent"),
6505 style: Style::default(),
6506 branch_prefix: String::new(),
6507 state_store: StateStore::Local,
6508 followups: crate::config::Followups::Issues,
6509 drafts: Drafts::Never,
6510 viewer: OnceLock::new(),
6511 checkpoints: Mutex::new(BTreeMap::new()),
6512 writes: WriteStats::default(),
6513 }
6514 }
6515
6516 #[test]
6517 fn write_results_accumulate_for_the_run() {
6518 let repo = repo_for_titles();
6519
6520 let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
6521 let _: std::result::Result<(), ()> = repo.record_write(Err(()));
6522
6523 assert_eq!(
6524 WriteSummary {
6525 attempted: 2,
6526 failed: 1,
6527 },
6528 repo.write_summary()
6529 );
6530 }
6531
6532 #[test]
6533 fn only_failed_write_preflights_join_the_summary() {
6534 let repo = repo_for_titles();
6535
6536 let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
6537 let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
6538
6539 assert_eq!(
6540 WriteSummary {
6541 attempted: 1,
6542 failed: 1,
6543 },
6544 repo.write_summary()
6545 );
6546 }
6547
6548 #[test]
6549 fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
6550 let repo = repo_for_titles();
6551
6552 assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
6553 assert_eq!(
6554 WriteSummary {
6555 attempted: 1,
6556 failed: 1,
6557 },
6558 repo.write_summary()
6559 );
6560 }
6561
6562 #[test]
6563 fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
6564 let mut repo = repo_for_titles();
6565 repo.followups = Followups::Local;
6566
6567 assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
6568 assert_eq!(WriteSummary::default(), repo.write_summary());
6569 }
6570
6571 #[test]
6572 fn a_failed_remote_state_read_stops_before_state_mutation() {
6573 let root = std::env::temp_dir().join(format!(
6574 "spar-state-preflight-{}-{}",
6575 std::process::id(),
6576 std::time::SystemTime::now()
6577 .duration_since(std::time::UNIX_EPOCH)
6578 .unwrap()
6579 .as_nanos()
6580 ));
6581 std::fs::create_dir_all(&root).unwrap();
6582 let _fixture = ReviewFixture { root: root.clone() };
6583 let mut repo = repo_for_titles();
6584 repo.root = root;
6585 repo.state_store = StateStore::Both;
6586 let state = PersistedState {
6587 version: 1,
6588 checkpoint: 4,
6589 round: 2,
6590 next_actor: "a".into(),
6591 status: Status::Pending,
6592 pr_head: "abc123".into(),
6593 ledger: Ledger::new(),
6594 filed: Vec::new(),
6595 open_findings: Vec::new(),
6596 disputes: Vec::new(),
6597 noted: Vec::new(),
6598 };
6599
6600 let error = repo
6601 .write_state_after_remote_read(
6602 7,
6603 &state,
6604 Err(crate::error::SparError::new("state comments unavailable")),
6605 )
6606 .unwrap_err();
6607
6608 assert!(error.to_string().contains("state comments unavailable"));
6609 assert!(!repo.state_path(7).exists());
6610 assert_eq!(0, repo.remembered_checkpoint(7));
6611 assert_eq!(
6612 WriteSummary {
6613 attempted: 1,
6614 failed: 1,
6615 },
6616 repo.write_summary()
6617 );
6618 }
6619
6620 #[test]
6621 fn only_known_build_and_cache_directories_are_generated_artifacts() {
6622 assert!(is_generated_artifact(Path::new("target/debug/artifact")));
6623 assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
6624 assert!(is_generated_artifact(Path::new(
6625 "package/node_modules/dependency/file.js"
6626 )));
6627 assert!(!is_generated_artifact(Path::new(
6628 "distribution/required-package.js"
6629 )));
6630 assert!(!is_generated_artifact(Path::new(
6631 "generated/required-fixture.txt"
6632 )));
6633 assert!(!is_generated_artifact(Path::new("local.env")));
6634 }
6635
6636 struct ReviewFixture {
6637 root: PathBuf,
6638 }
6639
6640 impl Drop for ReviewFixture {
6641 fn drop(&mut self) {
6642 let _ = std::fs::remove_dir_all(&self.root);
6643 }
6644 }
6645
6646 fn test_git(cwd: &Path, args: &[&str]) -> String {
6647 let output = Command::new("git")
6648 .args(args)
6649 .current_dir(cwd)
6650 .output()
6651 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
6652 assert!(
6653 output.status.success(),
6654 "git {args:?} failed: {}",
6655 String::from_utf8_lossy(&output.stderr)
6656 );
6657 String::from_utf8_lossy(&output.stdout).into_owned()
6658 }
6659
6660 fn review_fixture(
6661 tag: &str,
6662 number: i64,
6663 ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
6664 use std::sync::atomic::{AtomicU32, Ordering};
6665 static NEXT: AtomicU32 = AtomicU32::new(0);
6666 let id = NEXT.fetch_add(1, Ordering::Relaxed);
6667 let root =
6668 std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
6669 let origin = root.join("origin.git");
6670 let work = root.join("work");
6671 std::fs::create_dir_all(&origin).unwrap();
6672 std::fs::create_dir_all(&work).unwrap();
6673 test_git(&origin, &["init", "--bare", "-b", "main"]);
6674 test_git(&work, &["init", "-b", "main"]);
6675 test_git(&work, &["config", "user.email", "spar@example.invalid"]);
6676 test_git(&work, &["config", "user.name", "spar test"]);
6677 test_git(&work, &["config", "commit.gpgsign", "false"]);
6678 test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
6679 test_git(&work, &["config", "filter.drop.smudge", "cat"]);
6680 std::fs::write(work.join("README.md"), "seed\n").unwrap();
6681 std::fs::write(work.join("data.txt"), "old\n").unwrap();
6682 std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
6683 std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
6684 test_git(&work, &["add", "."]);
6685 test_git(&work, &["commit", "-m", "seed"]);
6686 test_git(
6687 &work,
6688 &["remote", "add", "origin", origin.to_str().unwrap()],
6689 );
6690 test_git(&work, &["push", "-u", "origin", "main"]);
6691 test_git(
6692 &work,
6693 &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
6694 );
6695 let cfg = crate::config::parse(
6696 "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
6697 )
6698 .unwrap();
6699 let repo = Repo::open(&work, &cfg).unwrap();
6700 let path = repo.worktree_for_pr_head(number).unwrap();
6701 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6702 (ReviewFixture { root }, repo, path, checkpoint)
6703 }
6704
6705 #[test]
6706 fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
6707 let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
6708
6709 repo.release_review_worktree_checked(901, &checkpoint)
6710 .unwrap();
6711
6712 assert!(!path.exists());
6713 }
6714
6715 #[test]
6716 fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
6717 let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
6718 let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
6719 std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
6720 test_git(&path, &["add", "recovery.txt"]);
6721 test_git(&path, &["commit", "-m", "recovery commit"]);
6722 let recovery = test_git(&path, &["rev-parse", "HEAD"]);
6723 test_git(&path, &["reset", "--hard", "main"]);
6724
6725 assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
6726 test_git(
6727 &path,
6728 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
6729 );
6730 }
6731
6732 #[test]
6733 fn a_review_ref_reflog_only_commit_prevents_deletion() {
6734 let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
6735 let local_ref = review_ref(921);
6736 let original = test_git(&path, &["rev-parse", &local_ref]);
6737 let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
6738 let recovery = test_git(
6739 &path,
6740 &[
6741 "commit-tree",
6742 tree.trim(),
6743 "-p",
6744 original.trim(),
6745 "-m",
6746 "review ref recovery",
6747 ],
6748 );
6749 test_git(
6750 &path,
6751 &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
6752 );
6753 test_git(
6754 &path,
6755 &["update-ref", &local_ref, original.trim(), recovery.trim()],
6756 );
6757
6758 assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
6759 assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
6760 test_git(
6761 &path,
6762 &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
6763 );
6764 }
6765
6766 #[test]
6767 fn an_unpublished_commit_message_draft_is_recoverable() {
6768 let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
6769 let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
6770 let git_dir = if raw.is_absolute() {
6771 raw
6772 } else {
6773 path.join(raw)
6774 };
6775 std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
6776
6777 assert!(repository_has_recoverable_work(&path, true).unwrap());
6778 assert_eq!(
6779 "unique recovery draft\n",
6780 std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
6781 );
6782 }
6783
6784 #[test]
6785 fn a_changed_review_worktree_is_retained_after_a_checked_read() {
6786 let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
6787 std::fs::write(path.join("README.md"), "recover me\n").unwrap();
6788
6789 let error = repo
6790 .release_review_worktree_checked(902, &checkpoint)
6791 .unwrap_err();
6792
6793 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6794 assert!(error.to_string().contains("kept for recovery"), "{error}");
6795 assert_eq!(
6796 "recover me\n",
6797 std::fs::read_to_string(path.join("README.md")).unwrap()
6798 );
6799 repo.release_review_worktree(902);
6800 }
6801
6802 #[test]
6803 fn a_review_commit_is_retained_after_a_checked_read() {
6804 let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
6805 std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
6806 test_git(&path, &["add", "review-note.txt"]);
6807 test_git(&path, &["commit", "-m", "local review recovery"]);
6808 let head = test_git(&path, &["rev-parse", "HEAD"]);
6809
6810 let error = repo
6811 .release_review_worktree_checked(903, &checkpoint)
6812 .unwrap_err();
6813
6814 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6815 assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
6816 assert_eq!(
6817 "recover me\n",
6818 std::fs::read_to_string(path.join("review-note.txt")).unwrap()
6819 );
6820 repo.release_review_worktree(903);
6821 }
6822
6823 #[test]
6824 fn an_ignored_review_file_is_retained_after_a_checked_read() {
6825 let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
6826 std::fs::create_dir_all(path.join("generated")).unwrap();
6827 std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
6828
6829 let error = repo
6830 .release_review_worktree_checked(904, &checkpoint)
6831 .unwrap_err();
6832
6833 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6834 assert_eq!(
6835 "recover me\n",
6836 std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
6837 );
6838 repo.release_review_worktree(904);
6839 }
6840
6841 #[test]
6842 fn a_preexisting_ignored_review_file_change_is_retained() {
6843 let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
6844 std::fs::create_dir_all(path.join("generated")).unwrap();
6845 let ignored = path.join("generated/recovery.txt");
6846 std::fs::write(&ignored, "before\n").unwrap();
6847 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6848 std::fs::write(&ignored, "after!\n").unwrap();
6849
6850 let error = repo
6851 .release_review_worktree_checked(905, &checkpoint)
6852 .unwrap_err();
6853
6854 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6855 assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
6856 repo.release_review_worktree(905);
6857 }
6858
6859 #[test]
6860 fn a_preexisting_ignored_review_file_prevents_checked_removal() {
6861 let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
6862 std::fs::create_dir_all(path.join("generated")).unwrap();
6863 let ignored = path.join("generated/recovery.txt");
6864 std::fs::write(&ignored, "keep me\n").unwrap();
6865 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6866
6867 let error = repo
6868 .release_review_worktree_checked(906, &checkpoint)
6869 .unwrap_err();
6870
6871 assert!(error.to_string().contains("recoverable"), "{error}");
6872 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
6873 }
6874
6875 #[test]
6876 fn overwriting_a_preexisting_untracked_file_is_detected() {
6877 let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
6878 let untracked = path.join("notes.txt");
6879 std::fs::write(&untracked, "before\n").unwrap();
6880 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
6881 std::fs::write(&untracked, "after!\n").unwrap();
6882
6883 let error = repo
6884 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6885 .unwrap_err();
6886
6887 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6888 assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
6889 }
6890
6891 #[test]
6892 fn an_assume_unchanged_edit_is_detected() {
6893 let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
6894 test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
6895 std::fs::write(path.join("README.md"), "hidden\n").unwrap();
6896
6897 let error = repo
6898 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6899 .unwrap_err();
6900
6901 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6902 assert_eq!(
6903 "hidden\n",
6904 std::fs::read_to_string(path.join("README.md")).unwrap()
6905 );
6906 }
6907
6908 #[test]
6909 fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
6910 let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
6911 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
6912 test_git(&path, &["add", "README.md"]);
6913 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
6914
6915 let error = repo
6916 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6917 .unwrap_err();
6918
6919 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6920 assert_eq!(
6921 b"seed\r\n",
6922 std::fs::read(path.join("README.md")).unwrap().as_slice()
6923 );
6924 }
6925
6926 #[cfg(unix)]
6927 #[test]
6928 fn a_mode_edit_is_detected_when_filemode_is_disabled() {
6929 use std::os::unix::fs::PermissionsExt;
6930
6931 let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
6932 test_git(&path, &["config", "core.filemode", "false"]);
6933 let readme = path.join("README.md");
6934 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
6935 permissions.set_mode(0o755);
6936 std::fs::set_permissions(&readme, permissions).unwrap();
6937 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
6938
6939 let error = repo
6940 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
6941 .unwrap_err();
6942
6943 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6944 assert_eq!(
6945 0o755,
6946 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
6947 );
6948 }
6949
6950 #[test]
6951 fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
6952 let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
6953 std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
6954 test_git(&path, &["add", ".gitattributes"]);
6955 test_git(&path, &["commit", "-m", "select data filter"]);
6956 let baseline = repo.worktree_baseline(&path).unwrap();
6957 std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
6958
6959 assert!(repo
6960 .commit_pending_changes(&path, &baseline, "change data", "change data")
6961 .unwrap());
6962 let error = repo
6963 .refuse_unrepresented_tracked_changes(&path, &baseline)
6964 .unwrap_err();
6965
6966 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
6967 assert_eq!(
6968 "secret: recover me\nnew\n",
6969 std::fs::read_to_string(path.join("data.txt")).unwrap()
6970 );
6971 assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
6972 }
6973
6974 #[test]
6975 fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
6976 let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
6977 std::fs::create_dir_all(path.join("target")).unwrap();
6978 let untracked = path.join("target/user.yaml");
6979 std::fs::write(&untracked, "user data\n").unwrap();
6980 let baseline = repo.worktree_baseline(&path).unwrap();
6981 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
6982
6983 assert!(repo
6984 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
6985 .unwrap());
6986
6987 assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
6988 assert_eq!(
6989 "?? target/user.yaml\n",
6990 test_git(&path, &["status", "--short", "--untracked-files=all"])
6991 );
6992 assert!(test_git(
6993 &path,
6994 &[
6995 "ls-tree",
6996 "-r",
6997 "--name-only",
6998 "HEAD",
6999 "--",
7000 "target/user.yaml"
7001 ]
7002 )
7003 .is_empty());
7004 }
7005
7006 #[test]
7007 fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
7008 let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
7009 std::fs::create_dir_all(path.join("target")).unwrap();
7010 let untracked = path.join("target/user.yaml");
7011 std::fs::write(&untracked, "before\n").unwrap();
7012 let baseline = repo.worktree_baseline(&path).unwrap();
7013 let before = test_git(&path, &["rev-parse", "HEAD"]);
7014 std::fs::write(&untracked, "after\n").unwrap();
7015 std::fs::write(path.join("README.md"), "tracked change\n").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("target/user.yaml"), "{error}");
7023 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7024 assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
7025 assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
7026 }
7027
7028 #[test]
7029 fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
7030 let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
7031 let baseline = repo.worktree_baseline(&path).unwrap();
7032 std::fs::create_dir_all(path.join("target")).unwrap();
7033 std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
7034
7035 assert!(repo
7036 .commit_pending_changes(&path, &baseline, "add file", "add file")
7037 .unwrap());
7038
7039 assert_eq!(
7040 "new file\n",
7041 test_git(&path, &["show", "HEAD:target/new.txt"])
7042 );
7043 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7044 }
7045
7046 #[test]
7047 fn deleting_existing_ignored_work_stops_a_managed_commit() {
7048 let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7049 std::fs::create_dir_all(path.join("generated")).unwrap();
7050 let ignored = path.join("generated/keep.txt");
7051 std::fs::write(&ignored, "user data\n").unwrap();
7052 let baseline = repo.worktree_baseline(&path).unwrap();
7053 let before = test_git(&path, &["rev-parse", "HEAD"]);
7054 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7055 std::fs::remove_file(&ignored).unwrap();
7056
7057 let error = repo
7058 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7059 .unwrap_err();
7060
7061 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7062 assert!(error.to_string().contains("existing untracked"), "{error}");
7063 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7064 assert_eq!(
7065 "tracked change\n",
7066 std::fs::read_to_string(path.join("README.md")).unwrap()
7067 );
7068 }
7069
7070 #[test]
7071 fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
7072 let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
7073 let baseline = repo.worktree_baseline(&path).unwrap();
7074 let before = test_git(&path, &["rev-parse", "HEAD"]);
7075 std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7076 std::fs::create_dir_all(path.join("generated")).unwrap();
7077 let ignored = path.join("generated/recovery.txt");
7078 std::fs::write(&ignored, "keep me\n").unwrap();
7079
7080 let error = repo
7081 .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7082 .unwrap_err();
7083
7084 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7085 assert!(error.to_string().contains("recovery.txt"), "{error}");
7086 assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7087 assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7088 assert!(test_git(&path, &["status", "--porcelain"])
7089 .lines()
7090 .any(|line| line == "M README.md"));
7091 }
7092
7093 #[test]
7094 fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
7095 let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
7096 test_git(&path, &["config", "core.autocrlf", "true"]);
7097 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7098 assert_eq!(
7099 test_git(&path, &["hash-object", "README.md"]).trim(),
7100 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7101 );
7102
7103 assert!(repository_has_recoverable_work(&path, true).unwrap());
7104 assert_eq!(
7105 "seed\n",
7106 std::fs::read_to_string(path.join("README.md")).unwrap()
7107 );
7108 }
7109
7110 #[test]
7111 fn autocrlf_input_overrides_a_crlf_core_eol() {
7112 let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
7113 test_git(&path, &["config", "core.autocrlf", "input"]);
7114 test_git(&path, &["config", "core.eol", "crlf"]);
7115 std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7116 assert_eq!(
7117 test_git(&path, &["hash-object", "README.md"]).trim(),
7118 test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7119 );
7120
7121 assert!(repository_has_recoverable_work(&path, true).unwrap());
7122 assert_eq!(
7123 b"seed\r\n",
7124 std::fs::read(path.join("README.md")).unwrap().as_slice()
7125 );
7126 }
7127
7128 #[cfg(unix)]
7129 #[test]
7130 fn a_non_executable_permission_change_is_recoverable() {
7131 use std::os::unix::fs::PermissionsExt;
7132
7133 let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
7134 let readme = path.join("README.md");
7135 let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7136 permissions.set_mode(0o600);
7137 std::fs::set_permissions(&readme, permissions).unwrap();
7138 assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7139
7140 let error = repo
7141 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7142 .unwrap_err();
7143
7144 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7145 assert!(repository_has_recoverable_work(&path, true).unwrap());
7146 assert_eq!(
7147 0o600,
7148 std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7149 );
7150 }
7151
7152 #[cfg(unix)]
7153 #[test]
7154 fn a_managed_commit_skips_signing_and_hooks() {
7155 use std::os::unix::fs::PermissionsExt;
7156
7157 let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
7158 let common = common_git_dir(&path).unwrap();
7159 let hook = common.join("hooks/pre-commit");
7160 let marker = fixture.root.join("hook-ran");
7161 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
7162 std::fs::write(
7163 &hook,
7164 format!(
7165 "#!/bin/sh\nprintf ran > {}\nexit 1\n",
7166 sh_quote(marker.to_str().unwrap())
7167 ),
7168 )
7169 .unwrap();
7170 let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
7171 permissions.set_mode(0o755);
7172 std::fs::set_permissions(&hook, permissions).unwrap();
7173 test_git(&path, &["config", "commit.gpgsign", "true"]);
7174 test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
7175 std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
7176 test_git(&path, &["add", "managed.txt"]);
7177
7178 repo.commit_staged_changes(&path, "record managed change")
7179 .unwrap();
7180
7181 assert!(!marker.exists());
7182 assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
7183 }
7184
7185 #[test]
7186 fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
7187 let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
7188 std::fs::write(
7189 path.join(".gitattributes"),
7190 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
7191 )
7192 .unwrap();
7193 test_git(&path, &["add", ".gitattributes"]);
7194 test_git(&path, &["commit", "-m", "select automatic text"]);
7195 test_git(&path, &["config", "core.autocrlf", "true"]);
7196
7197 assert!(repository_has_recoverable_work(&path, true).unwrap());
7198 }
7199
7200 #[test]
7201 fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
7202 let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
7203 std::fs::write(
7204 path.join(".gitattributes"),
7205 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
7206 )
7207 .unwrap();
7208 test_git(&path, &["add", ".gitattributes"]);
7209 test_git(&path, &["commit", "-m", "select ident expansion"]);
7210 std::fs::write(path.join("README.md"), "seed\n").unwrap();
7211
7212 assert!(repository_has_recoverable_work(&path, true).unwrap());
7213 }
7214
7215 fn exclude_paths(repo: &Repo, lines: &[&str]) {
7218 use std::io::Write;
7219 let path = repo.root().join(".git").join("info").join("exclude");
7220 let mut file = std::fs::OpenOptions::new()
7221 .create(true)
7222 .append(true)
7223 .open(&path)
7224 .unwrap();
7225 for line in lines {
7226 writeln!(file, "{line}").unwrap();
7227 }
7228 }
7229
7230 #[test]
7231 fn a_read_only_inspection_may_rebuild_generated_output() {
7232 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
7233 exclude_paths(&repo, &["dist/"]);
7234 std::fs::create_dir_all(path.join("dist")).unwrap();
7235 std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7236 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7237 std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7238 std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7239
7240 repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7241 .unwrap();
7242 }
7243
7244 #[test]
7245 fn a_read_only_inspection_may_not_change_an_ignored_file_elsewhere() {
7246 let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
7247 exclude_paths(&repo, &["dist/", ".env.local"]);
7248 std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
7249 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7250 std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
7251
7252 let error = repo
7253 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7254 .unwrap_err();
7255
7256 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7257 }
7258
7259 #[test]
7260 fn build_output_alone_does_not_keep_a_worktree() {
7261 let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
7262 exclude_paths(&repo, &["target/", "dist/"]);
7263 std::fs::create_dir_all(path.join("target/debug")).unwrap();
7264 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7265 std::fs::create_dir_all(path.join("dist/cli")).unwrap();
7266 std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
7267
7268 assert!(!repository_has_recoverable_work(&path, true).unwrap());
7269 repo.release_review_worktree(933);
7270
7271 assert!(!path.exists());
7272 }
7273
7274 #[test]
7275 fn an_ignored_file_outside_build_output_keeps_a_worktree() {
7276 let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
7277 exclude_paths(&repo, &["target/", ".env.local"]);
7278 std::fs::create_dir_all(path.join("target/debug")).unwrap();
7279 std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7280 std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
7281
7282 assert!(repository_has_recoverable_work(&path, true).unwrap());
7283 repo.release_review_worktree(934);
7284
7285 assert_eq!(
7286 "TOKEN=keep me\n",
7287 std::fs::read_to_string(path.join(".env.local")).unwrap()
7288 );
7289 }
7290
7291 #[test]
7292 fn a_repository_nested_in_build_output_keeps_a_worktree() {
7293 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
7294 exclude_paths(&repo, &["node_modules/"]);
7295 let nested = path.join("node_modules/local-dep");
7296 std::fs::create_dir_all(&nested).unwrap();
7297 test_git(&nested, &["init"]);
7298 std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
7299
7300 assert!(repository_has_recoverable_work(&path, true).unwrap());
7301 repo.release_review_worktree(935);
7302
7303 assert!(nested.join(".git").exists());
7304 }
7305
7306 #[test]
7307 fn an_ordinary_untracked_file_keeps_a_worktree() {
7308 let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
7309 std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
7310
7311 assert!(repository_has_recoverable_work(&path, true).unwrap());
7312 repo.release_review_worktree(936);
7313
7314 assert_eq!(
7315 "somebody's notes\n",
7316 std::fs::read_to_string(path.join("notes.md")).unwrap()
7317 );
7318 }
7319
7320 #[test]
7321 fn a_legacy_crlf_checkout_is_retained_conservatively() {
7322 let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
7323 std::fs::write(
7324 path.join(".gitattributes"),
7325 ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
7326 )
7327 .unwrap();
7328 test_git(&path, &["add", ".gitattributes"]);
7329 test_git(&path, &["commit", "-m", "select legacy line endings"]);
7330
7331 assert!(repository_has_recoverable_work(&path, true).unwrap());
7332 }
7333
7334 #[test]
7335 fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
7336 let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
7337 let nested = path.join("tracked");
7338 std::fs::create_dir_all(&nested).unwrap();
7339 std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
7340 test_git(&path, &["add", "tracked/seed.txt"]);
7341 test_git(&path, &["commit", "-m", "add tracked directory"]);
7342 let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7343 test_git(&nested, &["init"]);
7344
7345 let error = repo
7346 .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7347 .unwrap_err();
7348
7349 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7350 assert!(error.to_string().contains("Git entry"), "{error}");
7351 assert!(nested.join(".git").exists());
7352 }
7353
7354 #[test]
7355 fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
7356 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
7357
7358 let state = ignored_untracked_state(repo.root()).unwrap();
7359
7360 let relative = path.strip_prefix(repo.root()).unwrap();
7361 assert!(
7362 state.files.contains_key(relative),
7363 "{:?}",
7364 state.files.keys().collect::<Vec<_>>()
7365 );
7366 assert!(state.is_ignored(relative));
7367 }
7368
7369 #[test]
7370 fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
7371 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
7372 let baseline = repo.worktree_baseline(repo.root()).unwrap();
7373 std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
7374 std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
7375
7376 repo.refuse_new_ignored_files(repo.root(), &baseline)
7377 .unwrap();
7378 repo.refuse_changed_existing_untracked(repo.root(), &baseline)
7379 .unwrap();
7380 }
7381
7382 #[test]
7383 fn deleting_a_resident_worktree_during_a_call_is_refused() {
7384 let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
7385 let baseline = repo.worktree_baseline(repo.root()).unwrap();
7386 std::fs::remove_dir_all(&path).unwrap();
7387
7388 let error = repo
7389 .refuse_new_ignored_files(repo.root(), &baseline)
7390 .unwrap_err();
7391
7392 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7393 assert!(error.to_string().contains("review-932"), "{error}");
7394 }
7395
7396 #[test]
7397 fn a_nested_repository_record_is_read_as_a_plain_path() {
7398 let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
7399 assert_eq!(Path::new("vendor/checkout"), path);
7400 assert!(nested);
7401
7402 let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
7403 assert_eq!(Path::new("vendor/notes.txt"), path);
7404 assert!(!nested);
7405
7406 assert!(untracked_record(b"/", "untracked").is_err());
7407 }
7408
7409 #[cfg(unix)]
7410 #[test]
7411 fn a_non_utf8_git_path_is_preserved_without_loss() {
7412 use std::os::unix::ffi::OsStrExt;
7413
7414 let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
7415
7416 assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
7417 }
7418
7419 #[test]
7420 fn guarded_merge_pins_the_reviewed_head() {
7421 let args = merge_pr_args("36", Some("abc123"), true);
7422 assert_eq!(
7423 vec![
7424 "pr",
7425 "merge",
7426 "36",
7427 "--squash",
7428 "--delete-branch",
7429 "--match-head-commit",
7430 "abc123"
7431 ],
7432 args
7433 );
7434 }
7435
7436 #[test]
7437 fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
7438 let pr = PrRef {
7439 number: 7,
7440 url: "https://example.test/pull/7".into(),
7441 title: "part one".into(),
7442 };
7443 let result = reconcile_pr_creation(
7444 "split-34-1",
7445 Err(crate::error::SparError::new("connection lost")),
7446 Ok(Some(pr)),
7447 )
7448 .unwrap();
7449 assert_eq!(7, result.number);
7450 }
7451
7452 #[test]
7453 fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
7454 let error = reconcile_pr_creation(
7455 "split-34-1",
7456 Err(crate::error::SparError::new("permission denied")),
7457 Ok(None),
7458 )
7459 .unwrap_err();
7460 assert!(error.to_string().contains("permission denied"), "{error}");
7461 }
7462
7463 #[test]
7464 fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
7465 let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
7466 assert!(pr_for_base(text, "split-34-2", "split-34-1")
7467 .unwrap()
7468 .is_none());
7469 let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
7470 assert_eq!(7, found.number);
7471 }
7472
7473 #[test]
7474 fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
7475 let result = reconcile_comment_post(
7476 34,
7477 "the summary",
7478 crate::error::SparError::new("connection lost"),
7479 Ok(vec![serde_json::json!({"body": "the summary"})]),
7480 );
7481 assert!(result.is_ok(), "{result:?}");
7482 }
7483
7484 #[test]
7485 fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
7486 let error = reconcile_comment_post(
7487 34,
7488 "the summary",
7489 crate::error::SparError::new("connection lost"),
7490 Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
7491 )
7492 .unwrap_err();
7493 assert_eq!("connection lost", error.to_string());
7494 }
7495
7496 #[test]
7497 fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
7498 let error = reconcile_comment_post(
7499 34,
7500 "the summary",
7501 crate::error::SparError::new("connection lost"),
7502 Err(crate::error::SparError::new("comments unavailable")),
7503 )
7504 .unwrap_err();
7505 assert!(
7506 error.to_string().contains("could not be verified"),
7507 "{error}"
7508 );
7509 assert!(
7510 error.to_string().contains("comments unavailable"),
7511 "{error}"
7512 );
7513 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7514 assert!(!error.worth_retrying());
7515 }
7516
7517 #[test]
7518 fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
7519 let result = reconcile_issue_edit(
7520 34,
7521 "wanted body",
7522 crate::error::SparError::new("connection lost"),
7523 Ok("wanted body".to_string()),
7524 );
7525 assert!(result.is_ok(), "{result:?}");
7526 }
7527
7528 #[test]
7529 fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
7530 let error = reconcile_issue_edit(
7531 34,
7532 "wanted body",
7533 crate::error::SparError::new("connection lost"),
7534 Err(crate::error::SparError::new("issue unavailable")),
7535 )
7536 .unwrap_err();
7537 assert!(
7538 error.to_string().contains("could not be verified"),
7539 "{error}"
7540 );
7541 assert!(error.to_string().contains("issue unavailable"), "{error}");
7542 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7543 assert!(!error.worth_retrying());
7544 }
7545
7546 #[test]
7547 fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
7548 let found = ExistingIssue {
7549 number: 101,
7550 url: "https://example.test/issues/101".into(),
7551 title: "child".into(),
7552 body: "body".into(),
7553 open: true,
7554 };
7555 let url = reconcile_issue_creation(
7556 "child",
7557 Err(crate::error::SparError::new("connection lost")),
7558 Ok(Some(found)),
7559 )
7560 .unwrap();
7561 assert_eq!("https://example.test/issues/101", url);
7562 }
7563
7564 #[test]
7565 fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
7566 let error = reconcile_issue_creation(
7567 "child",
7568 Err(crate::error::SparError::new("permission denied")),
7569 Ok(None),
7570 )
7571 .unwrap_err();
7572 assert!(error.to_string().contains("permission denied"), "{error}");
7573 }
7574
7575 #[test]
7576 fn an_unverifiable_issue_creation_is_marked_uncertain() {
7577 let error = reconcile_issue_creation(
7578 "child",
7579 Err(crate::error::SparError::new("connection lost")),
7580 Err(crate::error::SparError::new("issues unavailable")),
7581 )
7582 .unwrap_err();
7583 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7584 assert!(!error.worth_retrying());
7585 }
7586
7587 #[test]
7588 fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
7589 let result = reconcile_failed_split_push(
7590 "split-34-1",
7591 crate::error::SparError::new("connection lost"),
7592 Ok("abc123\n".into()),
7593 Ok("abc123\trefs/heads/split-34-1\n".into()),
7594 );
7595 assert!(result.is_ok(), "{result:?}");
7596 }
7597
7598 #[test]
7599 fn a_split_push_collision_is_definite_and_never_overwrites() {
7600 let error = reconcile_failed_split_push(
7601 "split-34-1",
7602 crate::error::SparError::new("lease rejected"),
7603 Ok("abc123\n".into()),
7604 Ok("def456\trefs/heads/split-34-1\n".into()),
7605 )
7606 .unwrap_err();
7607 assert!(!error.retain_worktree());
7608 assert!(
7609 error.to_string().contains("Nothing was overwritten"),
7610 "{error}"
7611 );
7612 }
7613
7614 #[test]
7615 fn an_unreadable_split_push_result_keeps_the_worktree() {
7616 let error = reconcile_failed_split_push(
7617 "split-34-1",
7618 crate::error::SparError::new("connection lost"),
7619 Ok("abc123\n".into()),
7620 Err(crate::error::SparError::new("origin unavailable")),
7621 )
7622 .unwrap_err();
7623 assert!(error.retain_worktree());
7624 assert!(error.to_string().contains("could not confirm"), "{error}");
7625 }
7626
7627 #[test]
7631 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
7632 let repo = repo_for_titles();
7633 for raw in [
7634 "Retry loop spins \u{2014} Retry-After parses to zero",
7635 "plain title",
7636 " spread over\nlines ",
7637 "\u{1F916} Generated with something",
7638 &format!("a \u{2014} {}", "very long title ".repeat(20)),
7639 &"x".repeat(300),
7640 &format!("{} \u{2014} end", "y".repeat(88)),
7641 &{
7646 let tail = "a\u{2014}b c\u{2014}d";
7647 let pad = Style::default().max_title_chars - tail.chars().count();
7648 format!("{}{tail}", "w".repeat(pad))
7649 },
7650 ] {
7651 let once = repo.clean_title(raw).unwrap();
7652 let twice = repo.clean_title(&once).unwrap();
7653 assert_eq!(once, twice, "not idempotent for {raw:?}");
7654 assert!(
7655 once.chars().count() <= repo.style.max_title_chars,
7656 "over budget: {once:?}"
7657 );
7658 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
7659 }
7660 }
7661
7662 #[test]
7663 fn a_title_with_an_em_dash_survives_as_readable_text() {
7664 let repo = repo_for_titles();
7665 assert_eq!(
7666 "Retry loop spins, Retry-After parses to zero",
7667 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
7668 .unwrap()
7669 );
7670 }
7671
7672 #[test]
7673 fn sh_quote_survives_a_quote() {
7674 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
7675 }
7676
7677 #[test]
7678 fn sh_quote_wraps_a_space() {
7679 assert_eq!(
7680 "'/Applications/My App/spar'",
7681 sh_quote("/Applications/My App/spar")
7682 );
7683 }
7684
7685 #[test]
7686 fn finished_states_are_recognised_case_insensitively() {
7687 assert!(is_finished("MERGED"));
7688 assert!(is_finished("closed"));
7689 assert!(!is_finished("OPEN"));
7690 assert!(!is_finished(""));
7691 }
7692
7693 fn state() -> PersistedState {
7694 PersistedState {
7695 version: 1,
7696 checkpoint: 0,
7697 round: 4,
7698 next_actor: "codex".into(),
7699 status: Status::Pending,
7700 pr_head: "abc123".into(),
7701 ledger: Ledger::new(),
7702 filed: vec![],
7703 open_findings: vec![Finding {
7704 severity: Severity::Blocking,
7705 title: "Unchecked error".into(),
7706 detail: "the failure is discarded".into(),
7707 file: "src/a.rs:12".into(),
7708 ..Finding::default()
7709 }],
7710 disputes: vec![Dispute {
7711 title: "Retry limit".into(),
7712 file: "src/net.rs".into(),
7713 reasoning: "the caller already bounds it".into(),
7714 }],
7715 noted: vec![Finding {
7716 severity: Severity::NonBlocking,
7717 title: "Timeout is fixed".into(),
7718 file: "src/config.rs".into(),
7719 ..Finding::default()
7720 }],
7721 }
7722 }
7723
7724 #[test]
7725 fn a_state_comment_round_trips() {
7726 let body = format!(
7727 "{STATE_MARKER}\n{}\n-->",
7728 serde_json::to_string(&state()).unwrap()
7729 );
7730 let back = parse_state_comment(&body).unwrap();
7731 assert_eq!(4, back.round);
7732 assert_eq!("codex", back.next_actor);
7733 assert_eq!("abc123", back.pr_head);
7734 assert_eq!("Unchecked error", back.open_findings[0].title);
7735 assert_eq!("src/net.rs", back.disputes[0].file);
7736 assert_eq!("Timeout is fixed", back.noted[0].title);
7737 }
7738
7739 #[test]
7740 fn old_state_without_new_lists_still_parses() {
7741 let body = format!(
7742 "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
7743 \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
7744 );
7745 let back = parse_state_comment(&body).expect("old state");
7746 assert!(back.open_findings.is_empty());
7747 assert!(back.disputes.is_empty());
7748 assert!(back.noted.is_empty());
7749 assert!(back.pr_head.is_empty());
7750 assert_eq!(0, back.checkpoint);
7751 }
7752
7753 #[test]
7754 fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
7755 let mut local = state();
7756 local.pr_head = "old".into();
7757 local.round = 9;
7758 let mut remote = state();
7759 remote.pr_head = "current".into();
7760 remote.round = 4;
7761
7762 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
7763 assert_eq!("current", chosen.pr_head);
7764 assert_eq!(4, chosen.round);
7765 }
7766
7767 #[test]
7768 fn checkpoint_order_breaks_same_round_ties() {
7769 let mut local = state();
7770 local.pr_head = "current".into();
7771 local.round = 4;
7772 local.checkpoint = 8;
7773 let mut remote = local.clone();
7774 remote.checkpoint = 7;
7775 remote.open_findings.clear();
7776
7777 let chosen = choose_state_for_head(vec![local], "current").unwrap();
7778 assert_eq!(8, chosen.checkpoint);
7779
7780 let mut local = state();
7781 local.pr_head = "current".into();
7782 local.round = 4;
7783 local.checkpoint = 8;
7784 let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
7785 assert_eq!(8, chosen.checkpoint);
7786 }
7787
7788 #[test]
7789 fn legacy_same_round_tie_keeps_the_local_checkpoint() {
7790 let mut local = state();
7791 local.pr_head = "current".into();
7792 local.round = 4;
7793 local.open_findings.push(Finding {
7794 title: "local checkpoint".into(),
7795 ..Finding::default()
7796 });
7797 let mut remote = state();
7798 remote.pr_head = "current".into();
7799 remote.round = 4;
7800
7801 let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
7802 assert_eq!(
7803 "local checkpoint",
7804 chosen.open_findings.last().unwrap().title
7805 );
7806 }
7807
7808 #[test]
7810 fn the_state_block_is_an_html_comment() {
7811 let body = format!(
7812 "{STATE_MARKER}\n{}\n-->",
7813 serde_json::to_string(&state()).unwrap()
7814 );
7815 assert!(body.starts_with("<!--"));
7816 assert!(body.trim_end().ends_with("-->"));
7817 assert!(!body[..body.find('{').unwrap()].contains("-->"));
7818 }
7819
7820 #[test]
7821 fn an_unrelated_json_block_is_not_state() {
7822 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
7823 }
7824
7825 #[test]
7826 fn a_malformed_state_comment_is_none_not_a_panic() {
7827 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
7828 }
7829
7830 #[test]
7831 fn atomic_write_leaves_no_temp_file() {
7832 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
7833 let _ = std::fs::remove_dir_all(&dir);
7834 let path = dir.join("state").join("pr-7.json");
7835 write_json_atomic(&path, &state()).unwrap();
7836 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
7837 .unwrap()
7838 .flatten()
7839 .filter_map(|e| e.file_name().to_str().map(str::to_string))
7840 .collect();
7841 assert_eq!(vec!["pr-7.json".to_string()], files);
7842 let _ = std::fs::remove_dir_all(&dir);
7843 }
7844
7845 #[test]
7846 fn atomic_write_overwrites_rather_than_accumulating() {
7847 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
7848 let _ = std::fs::remove_dir_all(&dir);
7849 let path = dir.join("pr-7.json");
7850 for round in 1..4 {
7851 let mut s = state();
7852 s.round = round;
7853 write_json_atomic(&path, &s).unwrap();
7854 }
7855 let back: PersistedState =
7856 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
7857 assert_eq!(3, back.round);
7858 let _ = std::fs::remove_dir_all(&dir);
7859 }
7860
7861 #[test]
7862 fn style_from_env_defaults_to_enforcing() {
7863 std::env::remove_var("SPAR_BAN_EM_DASH");
7864 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
7865 let style = style_from_env();
7866 assert!(style.ban_em_dash && style.ban_ai_attribution);
7867 assert!(
7868 !style.terse,
7869 "the commit filter must not truncate a commit message"
7870 );
7871 }
7872}
7873
7874#[cfg(test)]
7875mod comment_page_tests {
7876 use super::*;
7877
7878 #[test]
7879 fn a_single_merged_array_is_read() {
7880 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
7881 assert_eq!(2, pages.len());
7882 assert_eq!(Some(2), pages[1]["id"].as_i64());
7883 }
7884
7885 #[test]
7886 fn concatenated_pages_from_an_older_gh_are_read_too() {
7887 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
7888 assert_eq!(2, pages.len());
7889 }
7890
7891 #[test]
7895 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
7896 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
7897 let pages = parse_comment_pages(text);
7898 assert_eq!(2, pages.len(), "{pages:?}");
7899 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
7900 }
7901
7902 #[test]
7903 fn empty_output_is_no_comments_not_a_panic() {
7904 assert!(parse_comment_pages("").is_empty());
7905 assert!(parse_comment_pages(" ").is_empty());
7906 assert!(parse_comment_pages("[]").is_empty());
7907 }
7908
7909 #[test]
7910 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
7911 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
7912 }
7913
7914 #[test]
7915 fn a_write_postcheck_rejects_truncated_comment_pages() {
7916 let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
7917 assert!(
7918 error.to_string().contains("unexpected comment pages"),
7919 "{error}"
7920 );
7921 }
7922
7923 #[test]
7924 fn a_write_postcheck_rejects_empty_or_non_array_output() {
7925 assert!(try_parse_comment_pages("").is_err());
7926 assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
7927 assert!(try_parse_comment_pages("[]").is_ok());
7928 }
7929
7930 #[test]
7931 fn state_is_found_in_the_last_matching_comment() {
7932 let payload = |round: u32| {
7933 format!(
7934 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
7935 )
7936 };
7937 let text = serde_json::to_string(&serde_json::json!([
7938 {"id": 1, "body": payload(1)},
7939 {"id": 2, "body": "looks good to me"},
7940 {"id": 3, "body": payload(5)},
7941 ]))
7942 .unwrap();
7943 let pages = parse_comment_pages(&text);
7944 let last = pages
7945 .iter()
7946 .rev()
7947 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
7948 .unwrap();
7949 assert_eq!(5, last.round);
7950 }
7951}
7952
7953#[cfg(test)]
7954mod linked_pr_tests {
7955 use super::*;
7956
7957 const REAL_PAYLOAD: &str = r#"[
7962 {"number":14252,"title":"fix: reject leading-dash branch names",
7963 "url":"https://github.com/cli/cli/pull/14252",
7964 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
7965 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
7966 "url":"https://github.com/cli/cli/issues/14238"}]},
7967 {"number":14217,"title":"another change",
7968 "url":"https://github.com/cli/cli/pull/14217",
7969 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
7970 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
7971 "url":"https://github.com/cli/cli/issues/9761"}]},
7972 {"number":14200,"title":"unlinked work",
7973 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
7974 ]"#;
7975
7976 #[test]
7977 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
7978 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
7979 assert_eq!(14252, pr.number);
7980 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
7981 }
7982
7983 #[test]
7984 fn the_right_pr_is_picked_out_of_several() {
7985 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
7986 }
7987
7988 #[test]
7989 fn an_issue_nobody_is_working_on_finds_nothing() {
7990 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
7991 }
7992
7993 #[test]
7994 fn an_unlinked_pr_is_never_matched() {
7995 for issue in [14200, 0, 1] {
7997 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
7998 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
7999 }
8000 }
8001 }
8002
8003 #[test]
8004 fn empty_or_broken_output_is_none_rather_than_a_panic() {
8005 assert!(find_linked_pr("", 1).is_none());
8006 assert!(find_linked_pr("[]", 1).is_none());
8007 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
8008 assert!(find_linked_pr("[{\"number\":", 1).is_none());
8009 }
8010
8011 #[test]
8013 fn pr_view_reads_the_cross_repository_flag() {
8014 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
8015 "baseRefName":"main","state":"OPEN",
8016 "closingIssuesReferences":[],"isCrossRepository":true}"#;
8017 let pr: PrView = serde_json::from_str(json).unwrap();
8018 assert!(pr.is_cross_repository);
8019 assert!(pr.is_open());
8020
8021 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
8022 assert!(
8023 !serde_json::from_str::<PrView>(&same_repo)
8024 .unwrap()
8025 .is_cross_repository
8026 );
8027 }
8028}
8029
8030#[cfg(test)]
8031mod min_number_tests {
8032 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
8038 let mut numbers: Vec<i64> = open.to_vec();
8039 numbers.sort_unstable();
8040 if min_number > 0 {
8041 numbers.retain(|n| *n >= min_number);
8042 }
8043 numbers.truncate(limit);
8044 numbers
8045 }
8046
8047 #[test]
8048 fn the_floor_is_applied_before_the_cap_not_after() {
8049 let open = [12, 13, 14, 480, 481, 482];
8050 assert_eq!(vec![480, 481], pick(&open, 2, 480));
8051 assert!(!pick(&open, 2, 480).is_empty());
8054 }
8055
8056 #[test]
8057 fn no_floor_keeps_the_old_behaviour() {
8058 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
8059 }
8060
8061 #[test]
8062 fn the_floor_is_inclusive() {
8063 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
8064 }
8065
8066 #[test]
8067 fn a_floor_above_everything_open_yields_nothing() {
8068 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
8069 }
8070}