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