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