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