1use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32use std::sync::OnceLock;
33use std::time::{Duration, Instant};
34
35use parking_lot::Mutex;
36
37use super::git_command;
38use forge_primitives::git::{FileStatus, GitBranch, GitIssueRef, GitPrInfo};
39use forge_primitives::git_diff::{
40 BaseState, GitBranchAhead, GitDiffFile, GitDiffSnapshot, GitDiffStats, LayerState, PrRefresh,
41 RepoGate,
42};
43use serde::Deserialize;
44use tokio::time::timeout;
45
46pub mod content;
47pub mod file;
48pub mod hunks;
49pub mod resolver;
50
51const STDOUT_SIZE_CAP: usize = 8 * 1024 * 1024;
59
60const COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
66
67#[cfg(any(test, feature = "testing"))]
78const SCAN_CHILDREN: u32 = 10;
79
80#[cfg(any(test, feature = "testing"))]
84pub const SCAN_WORST_CASE: Duration =
85 Duration::from_secs(COMMAND_TIMEOUT.as_secs() * SCAN_CHILDREN as u64);
86
87#[cfg(test)]
94pub(super) const TEST_GIT_BUDGET: Duration = Duration::from_secs(60);
95
96#[cfg(test)]
106pub(super) fn stall_git_at(dir: &std::path::Path, bin: &std::path::Path) {
107 stall_git_for(dir, bin, 5);
108}
109
110#[cfg(test)]
113pub(super) fn stall_git_for(dir: &std::path::Path, bin: &std::path::Path, secs: u64) {
114 use std::os::unix::fs::PermissionsExt as _;
115
116 let wrapper = bin.join("stalled-git");
117 std::fs::write(&wrapper, format!("#!/bin/sh\n/bin/sleep {secs}\nexec git \"$@\"\n"))
118 .expect("the wrapper");
119 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
120 crate::env::git_command::point_git_at(dir, &wrapper);
121}
122
123#[cfg(test)]
128pub(super) fn fail_fetch_at(dir: &std::path::Path, bin: &std::path::Path) {
129 use std::os::unix::fs::PermissionsExt as _;
130
131 let wrapper = bin.join("fetchless-git");
132 std::fs::write(
133 &wrapper,
134 "#!/bin/sh\ncase \"$*\" in\n *\" fetch \"*) echo 'fatal: forced fetch failure' >&2; \
135 exit 128 ;;\nesac\nexec git \"$@\"\n",
136 )
137 .expect("the wrapper");
138 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
139 crate::env::git_command::point_git_at(dir, &wrapper);
140}
141
142#[cfg(test)]
146pub(super) fn lock_fetch_at(dir: &std::path::Path, bin: &std::path::Path) {
147 use std::os::unix::fs::PermissionsExt as _;
148
149 let wrapper = bin.join("locked-git");
150 std::fs::write(
151 &wrapper,
152 "#!/bin/sh\ncase \"$*\" in\n *\" fetch \"*) echo \"fatal: cannot lock ref \
153 'refs/remotes/origin/main': File exists.\" >&2; exit 1 ;;\nesac\nexec git \"$@\"\n",
154 )
155 .expect("the wrapper");
156 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
157 crate::env::git_command::point_git_at(dir, &wrapper);
158}
159
160#[cfg(test)]
164pub(super) fn unanswerable_origin_at(dir: &std::path::Path, bin: &std::path::Path) {
165 use std::os::unix::fs::PermissionsExt as _;
166
167 let wrapper = bin.join("unanswerable-origin-git");
168 std::fs::write(
169 &wrapper,
170 "#!/bin/sh\ncase \"$*\" in\n *\" remote\"*) echo 'fatal: forced' >&2; exit 128 \
171 ;;\n *\" rev-parse --git-dir\"*) echo 'fatal: forced' >&2; exit 128 ;;\n\
172 esac\nexec git \"$@\"\n",
173 )
174 .expect("the wrapper");
175 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
176 crate::env::git_command::point_git_at(dir, &wrapper);
177}
178
179#[cfg(test)]
183const ISOLATED_GIT_CONFIG: &str = "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null";
184
185#[cfg(test)]
189fn write_wrapper(
190 dir: &std::path::Path,
191 bin: &std::path::Path,
192 name: &str,
193 stalls: &[(&str, &str)],
194 fetch_body: &str,
195) {
196 use std::fmt::Write as _;
197 use std::os::unix::fs::PermissionsExt as _;
198
199 let mut script = String::from("#!/bin/sh\ncase \"$*\" in\n");
200 writeln!(script, " *\" fetch \"*) {fetch_body} ;;").expect("the wrapper's fetch arm");
201 for (subcommand, body) in stalls {
202 writeln!(script, " *\" {subcommand} \"*) {body} ;;").expect("the wrapper's stall arm");
203 }
204 writeln!(script, "esac\n{ISOLATED_GIT_CONFIG} exec git \"$@\"")
205 .expect("the wrapper's fallback");
206 let wrapper = bin.join(name);
207 std::fs::write(&wrapper, script).expect("the wrapper");
208 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
209 crate::env::git_command::point_git_at(dir, &wrapper);
210}
211
212#[cfg(test)]
216fn without_machine_config_at(dir: &std::path::Path, bin: &std::path::Path) {
217 use std::os::unix::fs::PermissionsExt as _;
218
219 let script = format!("#!/bin/sh\n{ISOLATED_GIT_CONFIG} exec git \"$@\"\n");
220 let wrapper = bin.join("isolated-git");
221 std::fs::write(&wrapper, script).expect("the wrapper");
222 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
223 crate::env::git_command::point_git_at(dir, &wrapper);
224}
225
226#[cfg(test)]
229struct HeldFetch {
230 open: std::path::PathBuf,
232 release: std::path::PathBuf,
234 starts: std::path::PathBuf,
236 probes: std::path::PathBuf,
239}
240
241#[cfg(test)]
242impl HeldFetch {
243 fn starts(&self) -> usize {
244 std::fs::read_to_string(&self.starts).map(|text| text.lines().count()).unwrap_or_default()
245 }
246
247 fn probes(&self) -> usize {
248 std::fs::read_to_string(&self.probes).map(|text| text.lines().count()).unwrap_or_default()
249 }
250
251 fn open(&self) -> bool {
252 self.open.exists()
253 }
254
255 fn release(&self) {
256 std::fs::write(&self.release, b"go").expect("release the fetch");
257 }
258}
259
260#[cfg(test)]
264fn held_fetch_at(dir: &std::path::Path, bin: &std::path::Path) -> HeldFetch {
265 let open = bin.join("fetch-open");
266 let release = bin.join("fetch-release");
267 let starts = bin.join("fetch-starts");
268 let probes = bin.join("common-dir-probes");
269 let body = format!(
270 "echo start >> '{starts}'; : > '{open}'; \
271 while [ ! -e '{release}' ]; do /bin/sleep 0.02; done; exec git \"$@\"",
272 starts = starts.display(),
273 open = open.display(),
274 release = release.display(),
275 );
276 let probe = format!("echo probe >> '{}'", probes.display());
277 write_wrapper(dir, bin, "held-git", &[("rev-parse", &probe)], &body);
278 HeldFetch { open, release, starts, probes }
279}
280
281#[cfg(test)]
285async fn wait_for(ready: impl FnMut() -> bool, what: &str) {
286 wait_for_within(ready, what, Duration::from_secs(30)).await;
287}
288
289#[cfg(test)]
293async fn wait_for_within(mut ready: impl FnMut() -> bool, what: &str, bound: Duration) {
294 let mut waited = Duration::ZERO;
295 while !ready() {
296 assert!(waited < bound, "waited for {what}");
297 tokio::time::sleep(Duration::from_millis(10)).await;
298 waited += Duration::from_millis(10);
299 }
300}
301
302const TOP_FILE_COUNT: usize = 7;
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313enum RepoPresence {
314 Present,
316 Absent,
319 Unusable,
323}
324
325async fn repo_presence(cwd: &Path) -> RepoPresence {
343 repo_presence_within(cwd, COMMAND_TIMEOUT).await
344}
345
346async fn repo_presence_within(cwd: &Path, budget: Duration) -> RepoPresence {
348 let mut command = git_command::tokio_git_command(cwd);
349 command.arg("-C").arg(cwd).args(["rev-parse", "--git-dir"]).kill_on_drop(true);
350 match timeout(budget, command.output()).await {
351 Ok(Ok(output)) if output.status.success() => RepoPresence::Present,
352 Ok(Ok(_)) if dot_git_present(cwd) => RepoPresence::Unusable,
353 Ok(Ok(_)) => RepoPresence::Absent,
354 Ok(Err(_)) | Err(_) => RepoPresence::Unusable,
355 }
356}
357
358fn dot_git_present(cwd: &Path) -> bool {
364 !matches!(
365 cwd.join(".git").symlink_metadata(),
366 Err(err) if err.kind() == std::io::ErrorKind::NotFound
367 )
368}
369
370fn classify_rev_parse(output: GitOutput, presence: RepoPresence) -> Result<String, RepoGate> {
380 match output {
381 GitOutput::Ok(s) => Ok(s.trim().to_owned()),
382 GitOutput::Empty => Err(RepoGate::NotARepo),
383 GitOutput::Failed if presence == RepoPresence::Absent => Err(RepoGate::NotARepo),
384 GitOutput::Failed | GitOutput::Oversize => Err(RepoGate::ScannerFailed),
385 }
386}
387
388async fn rev_parse_gate(cwd: &Path, budget: Duration) -> Result<String, RepoGate> {
392 let output = run_git_within(cwd, &["rev-parse", "--abbrev-ref", "HEAD"], budget).await;
393 let presence = if matches!(output, GitOutput::Failed) {
394 repo_presence_within(cwd, budget).await
395 } else {
396 RepoPresence::Present
397 };
398 classify_rev_parse(output, presence)
399}
400
401const PR_REFETCH_INTERVAL: Duration = Duration::from_secs(300);
407
408const PUSHED_SHA_WALK_CAP: usize = 500;
413
414const WATCH_REFRESH_INTERVAL: Duration = Duration::from_secs(240);
418
419const DISPLAY_FETCH_BUDGET: Duration = Duration::from_secs(30);
427
428static LAST_WATCH_REFRESH: OnceLock<Mutex<HashMap<PathBuf, Instant>>> = OnceLock::new();
435
436static BASE_RUNS: OnceLock<Runs> = OnceLock::new();
441
442type RunKey = (PathBuf, String);
444
445type Runs = Mutex<HashMap<RunKey, std::sync::Weak<BaseRun>>>;
448
449struct BaseRun {
451 answer: tokio::sync::watch::Sender<Option<BaseRefresh>>,
452}
453
454impl BaseRun {
455 fn new() -> std::sync::Arc<Self> {
456 let (answer, _) = tokio::sync::watch::channel(None);
457 std::sync::Arc::new(Self { answer })
458 }
459
460 fn publish(&self, refresh: BaseRefresh) {
461 self.answer.send_replace(Some(refresh));
467 }
468
469 async fn wait(&self) -> BaseRefresh {
470 let mut answer = self.answer.subscribe();
471 if let Some(held) = answer.borrow().clone() {
472 return held;
473 }
474 let _ = answer.changed().await;
477 answer.borrow().clone().unwrap_or_else(|| BaseRefresh {
478 state: BaseState::Unverified,
479 base_ref: None,
480 error: Some("the base refresh ended without an answer".to_owned()),
481 cut: false,
482 unanswered: true,
483 at: Instant::now(),
484 })
485 }
486}
487
488fn join_base_run(key: RunKey) -> (std::sync::Arc<BaseRun>, bool) {
491 let mut runs = BASE_RUNS.get_or_init(|| Mutex::new(HashMap::new())).lock();
492 runs.retain(|_, weak| weak.strong_count() > 0);
493 if let Some(run) = runs.get(&key).and_then(std::sync::Weak::upgrade) {
494 return (run, false);
495 }
496 lead_base_run(&mut runs, key)
497}
498
499fn lead_base_run(
500 runs: &mut HashMap<RunKey, std::sync::Weak<BaseRun>>,
501 key: RunKey,
502) -> (std::sync::Arc<BaseRun>, bool) {
503 let run = BaseRun::new();
504 runs.insert(key, std::sync::Arc::downgrade(&run));
505 (run, true)
506}
507
508struct LeaderGuard {
512 run: std::sync::Arc<BaseRun>,
513 published: bool,
514}
515
516impl LeaderGuard {
517 fn new(run: std::sync::Arc<BaseRun>) -> Self {
518 Self { run, published: false }
519 }
520
521 fn publish(&mut self, refresh: BaseRefresh) {
522 self.run.publish(refresh);
523 self.published = true;
524 }
525}
526
527impl Drop for LeaderGuard {
528 fn drop(&mut self) {
529 if self.published {
530 return;
531 }
532 self.run.publish(BaseRefresh {
533 state: BaseState::Unverified,
534 base_ref: None,
535 error: Some("the base refresh ended without an answer".to_owned()),
536 cut: false,
537 unanswered: true,
538 at: Instant::now(),
539 });
540 }
541}
542
543#[derive(Debug, Clone, PartialEq, Eq)]
550pub struct BaseMeasurement {
551 pub at: Instant,
552 pub state: BaseState,
553 pub error: Option<String>,
554}
555
556static LAST_MEASURED: OnceLock<Mutex<HashMap<(PathBuf, String), BaseMeasurement>>> =
565 OnceLock::new();
566
567fn remember_measurement(cwd: &Path, default_branch: &str, refresh: &BaseRefresh) {
568 if refresh.unanswered || refresh.cut {
574 return;
575 }
576 let mut held = LAST_MEASURED.get_or_init(|| Mutex::new(HashMap::new())).lock();
577 let now = Instant::now();
578 held.retain(|_, measured| measurement_in_window(measured, now));
579 held.insert(
580 (cwd.to_path_buf(), default_branch.to_owned()),
581 BaseMeasurement {
582 at: refresh.at,
587 state: refresh.state,
588 error: refresh.error.clone(),
589 },
590 );
591}
592
593pub fn last_base_measurement(cwd: &Path, default_branch: &str) -> Option<BaseMeasurement> {
597 LAST_MEASURED
598 .get_or_init(|| Mutex::new(HashMap::new()))
599 .lock()
600 .get(&(cwd.to_path_buf(), default_branch.to_owned()))
601 .cloned()
602}
603
604#[cfg(any(test, feature = "testing"))]
608pub fn point_git_at_for_tests(cwd: &Path, program: &Path) {
609 crate::env::git_command::point_git_at(cwd, program);
610}
611
612#[cfg(any(test, feature = "testing"))]
614pub fn clear_git_point_for_tests(cwd: &Path) {
615 crate::env::git_command::clear_git_point(cwd);
616}
617
618async fn repo_key(cwd: &Path, budget: Duration) -> PathBuf {
628 let common = match run_git_within(cwd, &["rev-parse", "--git-common-dir"], budget).await {
629 GitOutput::Ok(path) => path.trim().to_owned(),
630 GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => return cwd.to_path_buf(),
631 };
632 let joined = cwd.join(common);
633 joined.canonicalize().unwrap_or(joined)
634}
635
636fn refresh_due(last: Option<Instant>, now: Instant, window: Duration) -> bool {
638 match last {
639 None => true,
640 Some(last) => now.saturating_duration_since(last) >= window,
641 }
642}
643
644fn claim_refresh_slot(key: &Path, now: Instant, window: Duration) -> bool {
647 let mut map = LAST_WATCH_REFRESH.get_or_init(|| Mutex::new(HashMap::new())).lock();
648 map.retain(|_, last| now.saturating_duration_since(*last) < window);
651 let due = refresh_due(map.get(key).copied(), now, window);
652 if due {
653 map.insert(key.to_path_buf(), now);
654 }
655 due
656}
657
658fn stamp_refresh_slot(key: &Path, now: Instant) {
661 LAST_WATCH_REFRESH
662 .get_or_init(|| Mutex::new(HashMap::new()))
663 .lock()
664 .insert(key.to_path_buf(), now);
665}
666
667#[cfg(test)]
676static FETCH_TASKS: OnceLock<Mutex<Vec<tokio::task::JoinHandle<()>>>> = OnceLock::new();
677
678fn track_background_fetch(fetch: tokio::task::JoinHandle<()>) {
682 #[cfg(test)]
683 FETCH_TASKS.get_or_init(|| Mutex::new(Vec::new())).lock().push(fetch);
684 #[cfg(not(test))]
685 drop(fetch);
686}
687
688#[cfg(test)]
694async fn settle_background_fetches() -> usize {
695 let tasks = std::mem::take(&mut *FETCH_TASKS.get_or_init(|| Mutex::new(Vec::new())).lock());
696 let settled = tasks.len();
697 for task in tasks {
698 let _ = task.await;
699 }
700 settled
701}
702
703fn kick_background_fetch(cwd: &Path, default_branch: &str, budget: Duration) {
720 if !default_branch.starts_with("origin/") {
721 return;
722 }
723 if !claim_refresh_slot(cwd, Instant::now(), WATCH_REFRESH_INTERVAL) {
724 return;
725 }
726 let cwd = cwd.to_path_buf();
727 let branch = default_branch.to_owned();
728 track_background_fetch(tokio::spawn(async move {
729 let _ = refresh_base(&cwd, &branch, budget).await;
730 }));
731}
732
733#[derive(Debug, Clone, PartialEq, Eq)]
735pub struct BaseRefresh {
736 pub state: BaseState,
738 pub base_ref: Option<String>,
742 pub error: Option<String>,
747 pub cut: bool,
750 pub unanswered: bool,
754 pub at: Instant,
761}
762
763pub async fn refresh_base_for_display(cwd: &Path, default_branch: &str) -> BaseRefresh {
779 let decided_at = Instant::now();
780 if !default_branch.starts_with("origin/") {
781 let deadline = Some(Instant::now() + COMMAND_TIMEOUT);
782 let (state, base_ref) = local_base(cwd, default_branch, deadline).await;
783 return BaseRefresh {
784 state,
785 base_ref,
786 error: None,
787 cut: false,
788 unanswered: false,
789 at: decided_at,
790 };
791 }
792 kick_background_fetch(cwd, default_branch, DISPLAY_FETCH_BUDGET);
797 stored_base(cwd, default_branch, decided_at).await
798}
799
800async fn stored_base(cwd: &Path, default_branch: &str, decided_at: Instant) -> BaseRefresh {
809 let measured = last_base_measurement(cwd, default_branch)
810 .filter(|measured| measurement_in_window(measured, decided_at));
811 let base_ref = local_tip(cwd, default_branch, COMMAND_TIMEOUT).await;
812 let tip_at = Instant::now();
813 let measured = measured.or_else(|| {
814 last_base_measurement(cwd, default_branch)
815 .filter(|measured| measured.at <= tip_at && measurement_in_window(measured, tip_at))
816 });
817 match measured {
818 Some(measured) => answer_measurement(measured, base_ref),
822 None => BaseRefresh {
823 state: BaseState::Unverified,
824 base_ref,
825 error: None,
826 cut: false,
827 unanswered: false,
828 at: decided_at,
829 },
830 }
831}
832
833pub async fn refresh_base_now(cwd: &Path, default_branch: &str) -> BaseRefresh {
841 let decided_at = Instant::now();
842 if !default_branch.starts_with("origin/") {
843 let deadline = Some(Instant::now() + COMMAND_TIMEOUT);
844 let (state, base_ref) = local_base(cwd, default_branch, deadline).await;
845 return BaseRefresh {
846 state,
847 base_ref,
848 error: None,
849 cut: false,
850 unanswered: false,
851 at: decided_at,
852 };
853 }
854 if claim_refresh_slot(cwd, decided_at, WATCH_REFRESH_INTERVAL) {
855 let refresh = refresh_base(cwd, default_branch, DISPLAY_FETCH_BUDGET).await;
860 give_back_slot(cwd, &refresh);
861 return refresh;
862 }
863 if let Some(measured) = last_base_measurement(cwd, default_branch)
867 .filter(|measured| measurement_in_window(measured, decided_at))
868 {
869 let base_ref = local_tip(cwd, default_branch, COMMAND_TIMEOUT).await;
870 answer_measurement(measured, base_ref)
871 } else {
872 let refresh = refresh_base(cwd, default_branch, DISPLAY_FETCH_BUDGET).await;
873 give_back_slot(cwd, &refresh);
874 refresh
875 }
876}
877
878fn answer_measurement(measured: BaseMeasurement, base_ref: Option<String>) -> BaseRefresh {
881 BaseRefresh {
882 state: if base_ref.is_some() { measured.state } else { BaseState::Unverified },
883 base_ref,
884 error: measured.error,
885 cut: false,
886 unanswered: false,
887 at: measured.at,
888 }
889}
890
891fn give_back_slot(cwd: &Path, refresh: &BaseRefresh) {
895 if !refresh.cut && !refresh.unanswered {
896 return;
897 }
898 if let Some(slots) = LAST_WATCH_REFRESH.get() {
899 slots.lock().remove(cwd);
900 }
901}
902
903fn measurement_in_window(measured: &BaseMeasurement, now: Instant) -> bool {
906 now.saturating_duration_since(measured.at) < WATCH_REFRESH_INTERVAL
907}
908
909#[cfg(test)]
915async fn refresh_base_within(cwd: &Path, default_branch: &str, budget: Duration) -> BaseRefresh {
916 refresh_base(cwd, default_branch, budget).await
917}
918
919async fn refresh_base(cwd: &Path, default_branch: &str, budget: Duration) -> BaseRefresh {
923 let key = (repo_key(cwd, COMMAND_TIMEOUT).await, default_branch.to_owned());
924 let refresh = refresh_once(cwd, default_branch, budget, key).await;
925 remember_measurement(cwd, default_branch, &refresh);
926 refresh
927}
928
929async fn refresh_once(
932 cwd: &Path,
933 default_branch: &str,
934 budget: Duration,
935 key: RunKey,
936) -> BaseRefresh {
937 let (run, leader) = join_base_run(key);
938 if leader {
939 let mut guard = LeaderGuard::new(std::sync::Arc::clone(&run));
940 let refresh = refresh_base_owned(cwd, default_branch, budget).await;
941 guard.publish(refresh.clone());
942 refresh
943 } else {
944 run.wait().await
945 }
946}
947
948async fn refresh_base_owned(cwd: &Path, default_branch: &str, budget: Duration) -> BaseRefresh {
951 let deadline = Some(Instant::now() + budget);
952 let ssh = if ssh_is_forges(cwd).await {
955 git_command::SshCommand::Forge
956 } else {
957 git_command::SshCommand::User
958 };
959 let Some(remote_branch) = default_branch.strip_prefix("origin/") else {
964 let (state, base_ref) = local_base(cwd, default_branch, deadline).await;
965 return BaseRefresh {
966 state,
967 base_ref,
968 error: None,
969 cut: false,
970 unanswered: false,
971 at: Instant::now(),
972 };
973 };
974 let fetched = fetch_base(cwd, remote_branch, deadline, budget, ssh).await;
975 if fetched.error.is_none() {
976 stamp_refresh_slot(cwd, Instant::now());
979 }
980 let base_ref = local_tip(cwd, default_branch, remaining(deadline)).await;
992 let state = if fetched.error.is_none() && base_ref.is_some() {
993 BaseState::Current
994 } else {
995 BaseState::Unverified
996 };
997 BaseRefresh {
998 state,
999 base_ref,
1000 error: fetched.error,
1001 cut: fetched.cut,
1002 unanswered: false,
1003 at: Instant::now(),
1004 }
1005}
1006
1007async fn local_base(
1018 cwd: &Path,
1019 default_branch: &str,
1020 deadline: Option<Instant>,
1021) -> (BaseState, Option<String>) {
1022 let local = local_tip(cwd, default_branch, remaining(deadline)).await;
1023 let state = match origin_remote(cwd, deadline).await {
1024 OriginRemote::Present => BaseState::Unverified,
1025 OriginRemote::Absent => {
1026 match local_base_tracks_upstream(cwd, default_branch, deadline).await {
1027 Some(false) => BaseState::Local,
1028 Some(true) | None => BaseState::Unverified,
1029 }
1030 }
1031 OriginRemote::Unanswered(reason) => {
1035 tracing::debug!(
1036 target: crate::logging::targets::ENV_GIT,
1037 cwd = %cwd.display(),
1038 event_name = "git_base_origin_probe_unanswered",
1039 message = "the origin probe could not answer, so a local base is not claimed",
1040 outcome = "unverified",
1041 reason = %reason,
1042 base = %default_branch,
1043 );
1044 BaseState::Unverified
1045 }
1046 };
1047 (state, local)
1048}
1049
1050async fn ssh_is_forges(cwd: &Path) -> bool {
1054 if std::env::var_os("GIT_SSH_COMMAND").is_some() || std::env::var_os("GIT_SSH").is_some() {
1055 return false;
1056 }
1057 let mut command = git_command::tokio_git_command(cwd);
1062 command.arg("-C").arg(cwd).args(["config", "--get", "core.sshCommand"]).kill_on_drop(true);
1063 match child_output(command.output(), COMMAND_TIMEOUT).await {
1064 Some(Ok(out)) if out.status.success() => out.stdout.iter().all(u8::is_ascii_whitespace),
1066 Some(Ok(out)) if out.status.code() == Some(1) && out.stdout.is_empty() => true,
1068 _ => false,
1071 }
1072}
1073
1074async fn fetch_base(
1084 cwd: &Path,
1085 remote_branch: &str,
1086 deadline: Option<Instant>,
1087 budget: Duration,
1088 ssh: git_command::SshCommand,
1089) -> Fetched {
1090 let refspec = format!("+refs/heads/{remote_branch}:refs/remotes/origin/{remote_branch}");
1091 let mut command = git_command::network_tokio_git_command(cwd, ssh);
1092 command
1093 .arg("-C")
1094 .arg(cwd)
1095 .args(["fetch", "--no-auto-gc", "--no-write-fetch-head", "--quiet", "origin", &refspec])
1096 .kill_on_drop(true);
1097 let left = remaining(deadline);
1104 match child_output(command.output(), left).await {
1105 Some(Ok(out)) if out.status.success() => Fetched { error: None, cut: false },
1106 Some(Ok(out)) => {
1107 let stderr = String::from_utf8_lossy(&out.stderr);
1108 let first = stderr.lines().next().unwrap_or_default();
1109 if is_lock_failure(&stderr) {
1110 tracing::debug!(
1118 target: crate::logging::targets::ENV_GIT,
1119 cwd = %cwd.display(),
1120 event_name = "git_base_fetch_lock_refused",
1121 message = "the base fetch could not take a ref lock in the checkout",
1122 outcome = "failure",
1123 branch = %remote_branch,
1124 stderr = %first,
1125 );
1126 } else {
1127 tracing::debug!(
1128 target: crate::logging::targets::ENV_GIT,
1129 cwd = %cwd.display(),
1130 event_name = "git_base_fetch_nonzero_exit",
1131 message = "the base fetch exited non-zero",
1132 outcome = "failure",
1133 exit_code = out.status.code().unwrap_or(-1),
1134 branch = %remote_branch,
1135 stderr = %first,
1136 );
1137 }
1138 Fetched {
1142 error: Some(clipped(&redact(&first_line(&out.stderr)), FAILURE_WORDS_CAP)),
1143 cut: false,
1144 }
1145 }
1146 Some(Err(err)) => {
1147 tracing::debug!(
1148 target: crate::logging::targets::ENV_GIT,
1149 cwd = %cwd.display(),
1150 event_name = "git_base_fetch_failed",
1151 message = "the base fetch spawn / wait failed",
1152 outcome = "failure",
1153 error = %err,
1154 branch = %remote_branch,
1155 );
1156 Fetched { error: Some(format!("git could not be run: {err}")), cut: false }
1157 }
1158 None => {
1159 let given = budget.as_secs_f64();
1162 tracing::debug!(
1163 target: crate::logging::targets::ENV_GIT,
1164 cwd = %cwd.display(),
1165 event_name = "git_base_fetch_timeout",
1166 message = "the base fetch did not finish inside the budget it was given",
1167 outcome = "timeout",
1168 branch = %remote_branch,
1169 budget_s = given,
1170 );
1171 let words = format!("the fetch did not finish inside the {given}s it was given");
1172 Fetched { error: Some(words), cut: true }
1173 }
1174 }
1175}
1176
1177struct Fetched {
1179 error: Option<String>,
1181 cut: bool,
1183}
1184
1185fn first_line(stderr: &[u8]) -> String {
1188 let text = String::from_utf8_lossy(stderr);
1189 text.lines()
1190 .map(str::trim)
1191 .find(|line| !line.is_empty())
1192 .unwrap_or("git exited non-zero without saying why")
1193 .to_owned()
1194}
1195
1196const FAILURE_WORDS_CAP: usize = 200;
1200
1201fn redact(words: &str) -> String {
1207 let mut out = String::with_capacity(words.len());
1208 let mut rest = words;
1209 while let Some(at) = rest.find("://") {
1210 let (before, after) = rest.split_at(at + 3);
1211 out.push_str(before);
1212 let end = after
1214 .find(|c: char| c == '/' || c == '\'' || c == '"' || c.is_whitespace())
1215 .unwrap_or(after.len());
1216 let (authority, tail) = after.split_at(end);
1217 match authority.rfind('@') {
1218 Some(userinfo) => {
1219 out.push_str("<redacted>");
1220 out.push_str(&authority[userinfo..]);
1221 }
1222 None => out.push_str(authority),
1223 }
1224 rest = tail;
1225 }
1226 out.push_str(rest);
1227 out
1228}
1229
1230fn is_lock_failure(stderr: &str) -> bool {
1239 stderr.contains("cannot lock ref")
1240 || (stderr.contains("Unable to create") && stderr.contains(".lock"))
1241}
1242
1243const UNBOUNDED: Duration = Duration::MAX;
1248
1249fn remaining(deadline: Option<Instant>) -> Duration {
1252 deadline.map_or(UNBOUNDED, |deadline| deadline.saturating_duration_since(Instant::now()))
1253}
1254
1255async fn child_output(
1258 child: impl std::future::Future<Output = std::io::Result<std::process::Output>>,
1259 budget: Duration,
1260) -> Option<std::io::Result<std::process::Output>> {
1261 if budget == UNBOUNDED { Some(child.await) } else { timeout(budget, child).await.ok() }
1262}
1263
1264async fn local_tip(cwd: &Path, reference: &str, budget: Duration) -> Option<String> {
1266 if budget.is_zero() {
1267 return None;
1268 }
1269 match run_git_within(cwd, &["rev-parse", reference], budget).await {
1270 GitOutput::Ok(sha) => Some(sha.trim().to_owned()),
1271 GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => None,
1272 }
1273}
1274
1275enum OriginRemote {
1278 Present,
1279 Absent,
1280 Unanswered(&'static str),
1285}
1286
1287async fn origin_remote(cwd: &Path, deadline: Option<Instant>) -> OriginRemote {
1302 if remaining(deadline).is_zero() {
1303 return OriginRemote::Unanswered("budget_spent");
1304 }
1305 match run_git_within(cwd, &["remote"], remaining(deadline)).await {
1306 GitOutput::Ok(listing) => {
1307 if listing.lines().any(|name| name.trim() == "origin") {
1308 OriginRemote::Present
1309 } else {
1310 OriginRemote::Absent
1311 }
1312 }
1313 GitOutput::Empty => OriginRemote::Absent,
1315 GitOutput::Failed => match repo_presence_within(cwd, remaining(deadline)).await {
1316 RepoPresence::Absent => OriginRemote::Absent,
1318 RepoPresence::Present | RepoPresence::Unusable => {
1320 OriginRemote::Unanswered("git_refused")
1321 }
1322 },
1323 GitOutput::Oversize => OriginRemote::Unanswered("oversize"),
1324 }
1325}
1326
1327async fn local_base_tracks_upstream(
1332 cwd: &Path,
1333 base: &str,
1334 deadline: Option<Instant>,
1335) -> Option<bool> {
1336 if remaining(deadline).is_zero() {
1337 return None;
1338 }
1339 let spec = format!("{base}@{{upstream}}");
1340 match run_git_within(cwd, &["rev-parse", "--abbrev-ref", &spec], remaining(deadline)).await {
1341 GitOutput::Ok(_) => Some(true),
1342 GitOutput::Empty | GitOutput::Failed => Some(false),
1343 GitOutput::Oversize => None,
1344 }
1345}
1346
1347pub async fn current_branch(cwd: &Path) -> Result<Option<String>, RepoGate> {
1365 let name = rev_parse_gate(cwd, COMMAND_TIMEOUT).await?;
1366 Ok((name != "HEAD" && !name.is_empty()).then_some(name))
1367}
1368
1369pub async fn changed_file_count(cwd: &Path) -> Result<usize, RepoGate> {
1374 match run_git(cwd, &["status", "--porcelain"]).await {
1375 GitOutput::Ok(text) => Ok(text.lines().filter(|line| !line.trim().is_empty()).count()),
1376 GitOutput::Empty => Ok(0),
1377 GitOutput::Oversize => Err(RepoGate::ScannerFailed),
1378 GitOutput::Failed => match repo_presence(cwd).await {
1381 RepoPresence::Absent => Err(RepoGate::NotARepo),
1382 RepoPresence::Present | RepoPresence::Unusable => Err(RepoGate::ScannerFailed),
1383 },
1384 }
1385}
1386
1387pub async fn scan<S: std::hash::BuildHasher>(
1391 cwd: &Path,
1392 env: &HashMap<String, String, S>,
1393 prev: Option<&GitDiffSnapshot>,
1394) -> GitDiffSnapshot {
1395 scan_within(cwd, env, prev, None, PrRefresh::Fetch, true, COMMAND_TIMEOUT).await
1396}
1397
1398pub async fn scan_without_base_fetch<S: std::hash::BuildHasher>(
1403 cwd: &Path,
1404 env: &HashMap<String, String, S>,
1405 prev: Option<&GitDiffSnapshot>,
1406) -> GitDiffSnapshot {
1407 scan_within(cwd, env, prev, None, PrRefresh::Fetch, false, COMMAND_TIMEOUT).await
1408}
1409
1410pub async fn scan_serving_cached_pr<S: std::hash::BuildHasher>(
1416 cwd: &Path,
1417 env: &HashMap<String, String, S>,
1418 prev: Option<&GitDiffSnapshot>,
1419) -> GitDiffSnapshot {
1420 scan_within(cwd, env, prev, None, PrRefresh::ServeCached, false, COMMAND_TIMEOUT).await
1421}
1422
1423pub async fn scan_pinned<S: std::hash::BuildHasher>(
1429 cwd: &Path,
1430 env: &HashMap<String, String, S>,
1431 prev: Option<&GitDiffSnapshot>,
1432 base: &str,
1433) -> GitDiffSnapshot {
1434 scan_within(cwd, env, prev, Some(base), PrRefresh::ServeCached, false, COMMAND_TIMEOUT).await
1435}
1436
1437async fn scan_within<S: std::hash::BuildHasher>(
1450 cwd: &Path,
1451 env: &HashMap<String, String, S>,
1452 prev: Option<&GitDiffSnapshot>,
1453 base: Option<&str>,
1454 pr_refresh: PrRefresh,
1455 kick: bool,
1458 budget: Duration,
1459) -> GitDiffSnapshot {
1460 let raw_branch = match rev_parse_gate(cwd, budget).await {
1461 Ok(branch) => branch,
1462 Err(repo_gate) => {
1467 return GitDiffSnapshot {
1468 branch: GitBranch::NoRepo,
1469 default_branch: None,
1470 repo_gate,
1471 pushed_sha: None,
1472 worktree: LayerState::Clean,
1473 branch_ahead: LayerState::Clean,
1474 pr: None,
1475 closes: Vec::new(),
1476 pr_fetched_at: None,
1477 };
1478 }
1479 };
1480 let detached = raw_branch == "HEAD";
1481 let branch = if detached {
1482 GitBranch::Detached
1483 } else if raw_branch.is_empty() {
1484 GitBranch::Unknown
1485 } else {
1486 GitBranch::Named(raw_branch.clone())
1487 };
1488
1489 let default_branch = resolve_default_branch(cwd, budget).await;
1490 let on_default = checked_out_on_default(&raw_branch, default_branch.as_deref());
1491 if kick
1498 && pr_refresh == PrRefresh::Fetch
1499 && !detached
1500 && !on_default
1501 && let Some(default) = default_branch.as_deref()
1502 && default.starts_with("origin/")
1503 {
1504 kick_background_fetch(cwd, default, budget);
1505 }
1506 let dirty_probe = is_worktree_dirty(cwd, budget).await;
1507
1508 let worktree: LayerState<GitDiffStats> = match dirty_probe {
1515 None => LayerState::ScanFailed,
1516 Some(false) => LayerState::Clean,
1517 Some(true) => match numstat(cwd, &["diff", "--numstat", "HEAD"], budget).await {
1518 Ok(mut stats) => {
1519 if let Some(marks) = name_statuses(cwd, "HEAD", budget).await {
1523 for file in &mut stats.files {
1524 if let Some(status) = marks.get(&file.path) {
1525 file.status = *status;
1526 }
1527 }
1528 }
1529 LayerState::Populated(stats)
1530 }
1531 Err(_) => LayerState::ScanFailed,
1532 },
1533 };
1534
1535 let branch_ahead: LayerState<GitBranchAhead> = if !detached && !on_default {
1544 if let Some(default) = base.or(default_branch.as_deref()) {
1547 let range = format!("{default}...HEAD");
1548 match numstat(cwd, &["diff", "--numstat", &range], budget).await {
1549 Ok(stats) => match commit_count_in_range(cwd, default, "HEAD", budget).await {
1550 None => LayerState::ScanFailed,
1551 Some(0) => LayerState::Clean,
1552 Some(commit_count) => {
1553 let mut stats = stats;
1567 if let Some(marks) = name_statuses(cwd, &range, budget).await {
1568 for file in &mut stats.files {
1569 if let Some(status) = marks.get(&file.path) {
1570 file.status = *status;
1571 }
1572 }
1573 }
1574 let commits = commits_in_range(cwd, default, "HEAD", budget)
1578 .await
1579 .unwrap_or_default();
1580 LayerState::Populated(GitBranchAhead { commit_count, stats, commits })
1581 }
1582 },
1583 Err(_) => LayerState::ScanFailed,
1584 }
1585 } else {
1586 LayerState::Clean
1587 }
1588 } else {
1589 LayerState::Clean
1590 };
1591
1592 let (pr, closes, pr_fetched_at, pushed_sha) = match &branch {
1600 GitBranch::Named(name) if !on_default => {
1601 let pushed_sha = resolve_pushed_sha(cwd, PUSHED_SHA_WALK_CAP, budget).await;
1602 let (pr, closes, pr_fetched_at) =
1603 pr_for_head_within(cwd, env, name, pushed_sha.as_deref(), prev, pr_refresh, budget)
1604 .await;
1605 (pr, closes, pr_fetched_at, pushed_sha)
1606 }
1607 _ => (None, Vec::new(), None, None),
1608 };
1609
1610 GitDiffSnapshot {
1618 branch,
1619 default_branch,
1620 repo_gate: RepoGate::InRepo,
1621 pushed_sha,
1622 worktree,
1623 branch_ahead,
1624 pr,
1625 closes,
1626 pr_fetched_at,
1627 }
1628}
1629
1630async fn commit_count_in_range(
1637 cwd: &Path,
1638 base: &str,
1639 head: &str,
1640 budget: Duration,
1641) -> Option<u32> {
1642 let range = format!("{base}..{head}");
1643 match run_git_within(cwd, &["rev-list", "--count", &range], budget).await {
1644 GitOutput::Ok(s) => match s.trim().parse::<u32>() {
1645 Ok(count) => Some(count),
1646 Err(err) => {
1647 tracing::warn!(
1648 target: crate::logging::targets::ENV_GIT,
1649 cwd = %cwd.display(),
1650 event_name = "git_commit_count_parse_failed",
1651 message = "rev-list --count stdout did not parse as u32",
1652 outcome = "failure",
1653 error = %err,
1654 range = %range,
1655 raw = %s.trim(),
1656 );
1657 None
1658 }
1659 },
1660 GitOutput::Empty => {
1661 tracing::warn!(
1667 target: crate::logging::targets::ENV_GIT,
1668 cwd = %cwd.display(),
1669 event_name = "git_commit_count_empty",
1670 message = "rev-list --count returned empty stdout (expected at minimum '0\\n')",
1671 outcome = "anomalous",
1672 range = %range,
1673 );
1674 None
1675 }
1676 GitOutput::Failed | GitOutput::Oversize => None,
1677 }
1678}
1679
1680async fn resolve_default_branch(cwd: &Path, budget: Duration) -> Option<String> {
1689 if let GitOutput::Ok(s) =
1693 run_git_within(cwd, &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], budget).await
1694 {
1695 return Some(s.trim().to_owned());
1696 }
1697 for candidate in ["origin/main", "origin/master", "main", "master"] {
1700 if let GitOutput::Ok(_) =
1701 run_git_within(cwd, &["rev-parse", "--verify", candidate], budget).await
1702 {
1703 return Some(candidate.to_owned());
1704 }
1705 }
1706 tracing::warn!(
1711 target: crate::logging::targets::ENV_GIT,
1712 cwd = %cwd.display(),
1713 event_name = "git_default_branch_unknown",
1714 message = "default branch fallback exhausted (no origin/HEAD, origin/main, origin/master, main, or master)",
1715 outcome = "skipped",
1716 );
1717 None
1718}
1719
1720async fn is_worktree_dirty(cwd: &Path, budget: Duration) -> Option<bool> {
1727 match run_git_within(cwd, &["status", "--porcelain=v1", "--untracked-files=no"], budget).await {
1728 GitOutput::Ok(s) => Some(!s.trim().is_empty()),
1729 GitOutput::Empty => Some(false),
1730 GitOutput::Failed | GitOutput::Oversize => None,
1731 }
1732}
1733
1734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1741pub enum NumstatError {
1742 Subprocess,
1746 Oversize,
1749}
1750
1751async fn numstat(
1762 cwd: &Path,
1763 args: &[&str],
1764 budget: Duration,
1765) -> Result<GitDiffStats, NumstatError> {
1766 let raw = match run_git_within(cwd, args, budget).await {
1767 GitOutput::Ok(s) => s,
1768 GitOutput::Empty => String::new(),
1769 GitOutput::Failed => return Err(NumstatError::Subprocess),
1770 GitOutput::Oversize => return Err(NumstatError::Oversize),
1771 };
1772 Ok(stats_of(parse_numstat(&raw)))
1773}
1774
1775fn stats_of(mut files: Vec<GitDiffFile>) -> GitDiffStats {
1780 let total_files = files.len();
1781 let total_added: u32 = files.iter().fold(0u32, |acc, f| acc.saturating_add(f.added));
1782 let total_removed: u32 = files.iter().fold(0u32, |acc, f| acc.saturating_add(f.removed));
1783 files.sort_by(|a, b| {
1784 let a_total = a.added.saturating_add(a.removed);
1785 let b_total = b.added.saturating_add(b.removed);
1786 b_total.cmp(&a_total).then_with(|| a.path.cmp(&b.path))
1787 });
1788 files.truncate(TOP_FILE_COUNT);
1789 GitDiffStats { files, total_files, total_added, total_removed }
1790}
1791
1792fn parse_numstat(raw: &str) -> Vec<GitDiffFile> {
1800 raw.lines().filter_map(parse_numstat_line).collect()
1801}
1802
1803fn parse_numstat_line(line: &str) -> Option<GitDiffFile> {
1806 let mut parts = line.splitn(3, '\t');
1807 let added = parts.next()?;
1808 let removed = parts.next()?;
1809 let path = parts.next()?;
1810 if added == "-" || removed == "-" {
1811 return None;
1812 }
1813 let added = added.parse::<u32>().ok()?;
1814 let removed = removed.parse::<u32>().ok()?;
1815 Some(GitDiffFile {
1816 path: rename_target(path),
1817 added,
1818 removed,
1819 status: forge_primitives::git::FileStatus::Modified,
1820 })
1821}
1822
1823fn rename_target(path: &str) -> String {
1828 let Some((before, after)) = path.split_once(" => ") else {
1829 return path.to_owned();
1830 };
1831 match (before.rfind('{'), after.find('}')) {
1832 (Some(open), Some(close)) => {
1833 format!("{}{}{}", &before[..open], &after[..close], &after[close + 1..])
1834 }
1835 _ => after.to_owned(),
1836 }
1837}
1838
1839async fn name_statuses(
1844 cwd: &Path,
1845 target: &str,
1846 budget: Duration,
1847) -> Option<HashMap<String, FileStatus>> {
1848 match run_git_within(cwd, &["diff", "--name-status", target], budget).await {
1849 GitOutput::Ok(raw) => Some(
1850 self::hunks::parse_name_status_entries(&raw)
1851 .into_iter()
1852 .map(|entry| (entry.path, entry.status))
1853 .collect(),
1854 ),
1855 GitOutput::Empty => Some(HashMap::new()),
1856 GitOutput::Failed | GitOutput::Oversize => None,
1857 }
1858}
1859
1860async fn commits_in_range(
1868 cwd: &Path,
1869 base: &str,
1870 head: &str,
1871 budget: Duration,
1872) -> Option<Vec<forge_primitives::git::GitCommit>> {
1873 let range = format!("{base}..{head}");
1874 let cap = format!("--max-count={COMMITS_WALK_CAP}");
1875 let listed = match run_git_within(
1876 cwd,
1877 &["log", &cap, "--numstat", "--format=%h%x1f%s%x1f%ct", &range],
1878 budget,
1879 )
1880 .await
1881 {
1882 GitOutput::Ok(raw) => raw,
1883 GitOutput::Empty => return Some(Vec::new()),
1884 GitOutput::Failed | GitOutput::Oversize => return None,
1885 };
1886 let marks = match run_git_within(
1889 cwd,
1890 &["log", &cap, "--name-status", "--format=%h%x1f", &range],
1891 budget,
1892 )
1893 .await
1894 {
1895 GitOutput::Ok(raw) => parse_commit_marks(&raw),
1896 GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => HashMap::new(),
1897 };
1898
1899 let mut commits = Vec::new();
1900 let mut open: Option<(String, String, u64)> = None;
1901 let mut files: Vec<GitDiffFile> = Vec::new();
1902 for line in listed.lines() {
1903 let fields: Vec<&str> = line.split('\u{1f}').collect();
1904 if let [sha, subject, secs] = fields[..] {
1905 if let Some((sha, subject, time)) = open.take() {
1908 commits.push(forge_primitives::git::GitCommit {
1909 sha,
1910 subject,
1911 stats: stats_of(std::mem::take(&mut files)),
1912 time,
1913 });
1914 }
1915 open = Some((sha.to_owned(), subject.to_owned(), secs.parse().unwrap_or(0)));
1916 } else if let Some(file) = parse_numstat_line(line) {
1917 files.push(file);
1918 }
1919 }
1920 if let Some((sha, subject, time)) = open.take() {
1921 commits.push(forge_primitives::git::GitCommit {
1922 sha,
1923 subject,
1924 stats: stats_of(files),
1925 time,
1926 });
1927 }
1928 for commit in &mut commits {
1929 if let Some(by_path) = marks.get(&commit.sha) {
1930 for file in &mut commit.stats.files {
1931 if let Some(status) = by_path.get(&file.path) {
1932 file.status = *status;
1933 }
1934 }
1935 }
1936 }
1937 Some(commits)
1938}
1939
1940fn parse_commit_marks(raw: &str) -> HashMap<String, HashMap<String, FileStatus>> {
1944 let mut marks: HashMap<String, HashMap<String, FileStatus>> = HashMap::new();
1945 let mut open: Option<String> = None;
1946 for line in raw.lines() {
1947 if let Some((sha, _)) = line.split_once('\u{1f}') {
1948 open = Some(sha.to_owned());
1949 continue;
1950 }
1951 let Some(sha) = open.as_deref() else { continue };
1952 let mut parts = line.split('\t');
1954 let (Some(code), Some(path)) = (parts.next(), parts.next_back()) else {
1955 continue;
1956 };
1957 let Some(status) = code.chars().next().and_then(hunks::status_of) else {
1958 continue;
1959 };
1960 marks.entry(sha.to_owned()).or_default().insert(path.to_owned(), status);
1961 }
1962 marks
1963}
1964
1965const COMMITS_WALK_CAP: u32 = 20;
1969
1970pub fn pr_refresh_due(diff: &GitDiffSnapshot, now: std::time::SystemTime) -> bool {
1978 let GitBranch::Named(name) = &diff.branch else {
1979 return false;
1980 };
1981 !checked_out_on_default(name, diff.default_branch.as_deref())
1985 && diff.pr_fetched_at.is_none_or(|at| {
1986 !now.duration_since(at).is_ok_and(|elapsed| elapsed < PR_REFETCH_INTERVAL)
1987 })
1988}
1989
1990fn checked_out_on_default(branch: &str, default: Option<&str>) -> bool {
1994 default
1995 .map(|default| default.strip_prefix("origin/").unwrap_or(default))
1996 .is_some_and(|default| default == branch)
1997}
1998
1999fn pr_cache_fresh(
2007 prev: Option<&GitDiffSnapshot>,
2008 branch: &str,
2009 pushed_sha: Option<&str>,
2010 now: std::time::SystemTime,
2011) -> bool {
2012 let Some(prev) = prev else {
2013 return false;
2014 };
2015 let GitBranch::Named(prev_name) = &prev.branch else {
2016 return false;
2017 };
2018 prev_name == branch
2019 && prev.pushed_sha.as_deref() == pushed_sha
2020 && prev.pr_fetched_at.is_some_and(|at| {
2021 now.duration_since(at).is_ok_and(|elapsed| elapsed < PR_REFETCH_INTERVAL)
2022 })
2023}
2024
2025enum PrLookup {
2028 Found(GitPrInfo, Vec<GitIssueRef>),
2029 None,
2031 Failed,
2034}
2035
2036async fn pr_for_head_within<S: std::hash::BuildHasher>(
2045 cwd: &Path,
2046 env: &HashMap<String, String, S>,
2047 branch: &str,
2048 pushed_sha: Option<&str>,
2049 prev: Option<&GitDiffSnapshot>,
2050 pr_refresh: PrRefresh,
2051 budget: Duration,
2052) -> (Option<GitPrInfo>, Vec<GitIssueRef>, Option<std::time::SystemTime>) {
2053 let now = std::time::SystemTime::now();
2054 let cached = match prev {
2055 Some(prev) if pr_cache_fresh(Some(prev), branch, pushed_sha, now) => Some(prev),
2056 _ => None,
2057 };
2058 if let Some(prev) = cached {
2059 return (prev.pr.clone(), prev.closes.clone(), prev.pr_fetched_at);
2060 }
2061 if pr_refresh == PrRefresh::ServeCached {
2068 return match prev {
2069 Some(prev) if same_branch_and_sha(prev, branch, pushed_sha) => {
2070 (prev.pr.clone(), prev.closes.clone(), prev.pr_fetched_at)
2071 }
2072 _ => (None, Vec::new(), None),
2073 };
2074 }
2075 match fetch_pr_for_pushed_sha(cwd, env, pushed_sha, budget).await {
2076 PrLookup::Found(pr, closes) => (Some(pr), closes, Some(now)),
2077 PrLookup::None => (None, Vec::new(), Some(now)),
2078 PrLookup::Failed => match prev {
2079 Some(prev) if same_branch_and_sha(prev, branch, pushed_sha) => {
2080 (prev.pr.clone(), prev.closes.clone(), Some(now))
2081 }
2082 _ => (None, Vec::new(), Some(now)),
2083 },
2084 }
2085}
2086
2087fn same_branch_and_sha(prev: &GitDiffSnapshot, branch: &str, pushed_sha: Option<&str>) -> bool {
2091 matches!(&prev.branch, GitBranch::Named(prev_name) if prev_name == branch)
2092 && prev.pushed_sha.as_deref() == pushed_sha
2093}
2094
2095async fn resolve_pushed_sha(cwd: &Path, cap: usize, budget: Duration) -> Option<String> {
2106 let remote_shas = match run_git_within(
2107 cwd,
2108 &["for-each-ref", "refs/remotes", "--format=%(objectname)"],
2109 budget,
2110 )
2111 .await
2112 {
2113 GitOutput::Ok(raw) => raw
2114 .lines()
2115 .map(str::trim)
2116 .filter(|line| !line.is_empty())
2117 .map(str::to_owned)
2118 .collect::<std::collections::HashSet<_>>(),
2119 GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => {
2120 std::collections::HashSet::default()
2121 }
2122 };
2123 if remote_shas.is_empty() {
2124 return None;
2125 }
2126 let max_count = format!("--max-count={cap}");
2127 let walk =
2128 run_git_within(cwd, &["rev-list", "--first-parent", &max_count, "HEAD"], budget).await;
2129 let GitOutput::Ok(raw) = walk else {
2130 return None;
2131 };
2132 raw.lines().map(str::trim).find(|sha| remote_shas.contains(*sha)).map(str::to_owned)
2133}
2134
2135async fn fetch_pr_for_pushed_sha<S: std::hash::BuildHasher>(
2146 cwd: &Path,
2147 env: &HashMap<String, String, S>,
2148 pushed_sha: Option<&str>,
2149 budget: Duration,
2150) -> PrLookup {
2151 let Some(sha) = pushed_sha else {
2152 return PrLookup::None;
2153 };
2154 let endpoint = format!("repos/{{owner}}/{{repo}}/commits/{sha}/pulls");
2155 let raw = match run_gh(cwd, env, &["api", &endpoint], GhNotFound::Tolerate, budget).await {
2156 GitOutput::Ok(s) => s,
2157 GitOutput::Empty => {
2158 return PrLookup::None;
2162 }
2163 GitOutput::Failed | GitOutput::Oversize => return PrLookup::Failed,
2164 };
2165 match pick_open_pr(&raw) {
2166 Err(err) => {
2167 tracing::warn!(
2168 target: crate::logging::targets::ENV_GIT,
2169 cwd = %cwd.display(),
2170 event_name = "gh_pr_lookup_parse_failed",
2171 message = "gh api commits pulls returned unparseable json",
2172 outcome = "failure",
2173 error = %err,
2174 sha = %sha,
2175 );
2176 PrLookup::Failed
2177 }
2178 Ok(None) => PrLookup::None,
2179 Ok(Some((number, url, draft))) => {
2180 let closes = fetch_closing_issues(cwd, env, number, budget).await;
2181 PrLookup::Found(GitPrInfo { number, url, draft }, closes)
2182 }
2183 }
2184}
2185
2186fn pick_open_pr(raw: &str) -> Result<Option<(u64, String, bool)>, serde_json::Error> {
2196 let entries: Vec<GhApiPull> = serde_json::from_str(raw)?;
2197 Ok(entries
2198 .into_iter()
2199 .filter(|pr| pr.state == "open")
2200 .max_by(|a, b| (&a.updated_at, a.number).cmp(&(&b.updated_at, b.number)))
2201 .map(|pr| (pr.number, pr.html_url, pr.draft)))
2202}
2203
2204#[derive(Deserialize)]
2208struct GhApiPull {
2209 number: u64,
2210 state: String,
2211 html_url: String,
2212 updated_at: String,
2213 #[serde(default)]
2216 draft: bool,
2217}
2218
2219async fn fetch_closing_issues<S: std::hash::BuildHasher>(
2226 cwd: &Path,
2227 env: &HashMap<String, String, S>,
2228 number: u64,
2229 budget: Duration,
2230) -> Vec<GitIssueRef> {
2231 let number = number.to_string();
2232 let raw = match run_gh(
2233 cwd,
2234 env,
2235 &["pr", "view", &number, "--json", "closingIssuesReferences"],
2236 GhNotFound::Fail,
2237 budget,
2238 )
2239 .await
2240 {
2241 GitOutput::Ok(raw) => raw,
2242 GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => return Vec::new(),
2243 };
2244 let parsed: GhPrView = match serde_json::from_str(&raw) {
2245 Ok(parsed) => parsed,
2246 Err(err) => {
2247 tracing::warn!(
2248 target: crate::logging::targets::ENV_GIT,
2249 cwd = %cwd.display(),
2250 event_name = "gh_pr_lookup_parse_failed",
2251 message = "gh pr view returned unparseable json",
2252 outcome = "failure",
2253 error = %err,
2254 pr = %number,
2255 );
2256 return Vec::new();
2257 }
2258 };
2259 parsed
2260 .closing_issues
2261 .into_iter()
2262 .map(|issue| GitIssueRef { number: issue.number, url: issue.url })
2263 .collect()
2264}
2265
2266#[derive(Deserialize)]
2268struct GhPrView {
2269 #[serde(default, rename = "closingIssuesReferences")]
2270 closing_issues: Vec<GhIssueEntry>,
2271}
2272
2273#[derive(Deserialize)]
2276struct GhIssueEntry {
2277 number: u64,
2278 url: String,
2279}
2280
2281pub(super) enum GitOutput {
2289 Ok(String),
2290 Empty,
2292 Failed,
2298 Oversize,
2300}
2301
2302const STDERR_LOG_CAP: usize = 1024;
2306
2307fn clipped(text: &str, cap: usize) -> String {
2310 if text.len() <= cap {
2311 return text.to_owned();
2312 }
2313 let mut end = cap;
2314 while end > 0 && !text.is_char_boundary(end) {
2315 end -= 1;
2316 }
2317 format!("{}…", &text[..end])
2318}
2319
2320pub(super) async fn run_git(cwd: &Path, args: &[&str]) -> GitOutput {
2327 run_git_within(cwd, args, COMMAND_TIMEOUT).await
2328}
2329
2330pub(super) async fn run_git_within(cwd: &Path, args: &[&str], budget: Duration) -> GitOutput {
2338 let mut command = git_command::tokio_git_command(cwd);
2339 command.arg("-C").arg(cwd).args(args).kill_on_drop(true);
2340 classify_git(cwd, args, command, budget).await
2341}
2342
2343async fn classify_git(
2345 cwd: &Path,
2346 args: &[&str],
2347 mut command: tokio::process::Command,
2348 budget: Duration,
2349) -> GitOutput {
2350 let fut = command.output();
2351 let output = match child_output(fut, budget).await {
2352 Some(Ok(out)) => out,
2353 Some(Err(err)) => {
2354 tracing::debug!(
2355 target: crate::logging::targets::ENV_GIT,
2356 cwd = %cwd.display(),
2357 event_name = "git_subprocess_failed",
2358 message = "git subprocess spawn / wait failed",
2359 outcome = "failure",
2360 error = %err,
2361 args = ?args,
2362 );
2363 return GitOutput::Failed;
2364 }
2365 None => {
2366 tracing::debug!(
2367 target: crate::logging::targets::ENV_GIT,
2368 cwd = %cwd.display(),
2369 event_name = "git_subprocess_timeout",
2370 message = "git subprocess timed out",
2371 outcome = "timeout",
2372 args = ?args,
2373 );
2374 return GitOutput::Failed;
2375 }
2376 };
2377 if !output.status.success() {
2378 let stderr = String::from_utf8_lossy(&output.stderr);
2387 let stderr_truncated = clipped(&stderr, STDERR_LOG_CAP);
2388 tracing::debug!(
2389 target: crate::logging::targets::ENV_GIT,
2390 cwd = %cwd.display(),
2391 event_name = "git_subprocess_nonzero_exit",
2392 message = "git subprocess exited non-zero",
2393 outcome = "failure",
2394 exit_code = output.status.code().unwrap_or(-1),
2395 stderr = %stderr_truncated,
2396 args = ?args,
2397 );
2398 return GitOutput::Failed;
2399 }
2400 if output.stdout.len() > STDOUT_SIZE_CAP {
2401 tracing::warn!(
2402 target: crate::logging::targets::ENV_GIT,
2403 cwd = %cwd.display(),
2404 event_name = "git_subprocess_oversize",
2405 message = "git subprocess stdout exceeded size cap",
2406 outcome = "oversize",
2407 bytes = output.stdout.len(),
2408 args = ?args,
2409 );
2410 return GitOutput::Oversize;
2411 }
2412 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
2413 if stdout.trim().is_empty() { GitOutput::Empty } else { GitOutput::Ok(stdout) }
2414}
2415
2416enum GhNotFound {
2425 Fail,
2426 Tolerate,
2427}
2428
2429async fn run_gh<S: std::hash::BuildHasher>(
2438 cwd: &Path,
2439 env: &HashMap<String, String, S>,
2440 args: &[&str],
2441 not_found: GhNotFound,
2442 budget: Duration,
2443) -> GitOutput {
2444 let mut command = git_command::gh_tokio_command(env);
2445 command.current_dir(cwd).args(args).kill_on_drop(true);
2446 let fut = command.output();
2447 let output = match child_output(fut, budget).await {
2448 Some(Ok(out)) => out,
2449 Some(Err(err)) => {
2450 tracing::debug!(
2451 target: crate::logging::targets::ENV_GIT,
2452 cwd = %cwd.display(),
2453 event_name = "gh_subprocess_failed",
2454 message = "gh subprocess spawn / wait failed",
2455 outcome = "failure",
2456 error = %err,
2457 args = ?args,
2458 );
2459 return GitOutput::Failed;
2460 }
2461 None => {
2462 tracing::debug!(
2463 target: crate::logging::targets::ENV_GIT,
2464 cwd = %cwd.display(),
2465 event_name = "gh_subprocess_timeout",
2466 message = "gh subprocess timed out",
2467 outcome = "timeout",
2468 args = ?args,
2469 );
2470 return GitOutput::Failed;
2471 }
2472 };
2473 if !output.status.success() {
2474 let stderr = String::from_utf8_lossy(&output.stderr);
2475 if matches!(not_found, GhNotFound::Tolerate) && stderr.contains("No commit found for SHA") {
2482 tracing::debug!(
2483 target: crate::logging::targets::ENV_GIT,
2484 cwd = %cwd.display(),
2485 event_name = "gh_pr_lookup_unpushed_sha",
2486 message = "commit not on the remote; PR lookup skipped",
2487 outcome = "no_data",
2488 args = ?args,
2489 );
2490 return GitOutput::Empty;
2491 }
2492 let stderr_truncated = clipped(&stderr, STDERR_LOG_CAP);
2497 tracing::debug!(
2498 target: crate::logging::targets::ENV_GIT,
2499 cwd = %cwd.display(),
2500 event_name = "gh_subprocess_nonzero_exit",
2501 message = "gh subprocess exited non-zero",
2502 outcome = "failure",
2503 exit_code = output.status.code().unwrap_or(-1),
2504 stderr = %stderr_truncated,
2505 args = ?args,
2506 );
2507 return GitOutput::Failed;
2508 }
2509 if output.stdout.len() > STDOUT_SIZE_CAP {
2510 tracing::warn!(
2511 target: crate::logging::targets::ENV_GIT,
2512 cwd = %cwd.display(),
2513 event_name = "gh_subprocess_oversize",
2514 message = "gh subprocess stdout exceeded size cap",
2515 outcome = "oversize",
2516 bytes = output.stdout.len(),
2517 args = ?args,
2518 );
2519 return GitOutput::Oversize;
2520 }
2521 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
2522 if stdout.trim().is_empty() { GitOutput::Empty } else { GitOutput::Ok(stdout) }
2523}
2524
2525#[cfg(test)]
2526mod tests {
2527 use super::*;
2528 use std::path::PathBuf;
2529 use std::process::Command as StdCommand;
2530 use tempfile::TempDir;
2531
2532 #[derive(Clone, Default)]
2534 struct LogCapture(std::sync::Arc<parking_lot::Mutex<Vec<u8>>>);
2535
2536 impl std::io::Write for LogCapture {
2537 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2538 self.0.lock().extend_from_slice(buf);
2539 Ok(buf.len())
2540 }
2541 fn flush(&mut self) -> std::io::Result<()> {
2542 Ok(())
2543 }
2544 }
2545
2546 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
2547 type Writer = Self;
2548 fn make_writer(&'a self) -> Self::Writer {
2549 self.clone()
2550 }
2551 }
2552
2553 const TEST_LOOKUP_BUDGET: Duration = Duration::from_secs(60);
2562
2563 async fn scan_in_test(
2567 cwd: &std::path::Path,
2568 env: &HashMap<String, String>,
2569 prev: Option<&GitDiffSnapshot>,
2570 ) -> GitDiffSnapshot {
2571 scan_within(cwd, env, prev, None, PrRefresh::Fetch, true, TEST_GIT_BUDGET).await
2572 }
2573
2574 fn init_repo(dir: &TempDir, initial_branch: &str) {
2575 let path = dir.path();
2576 let run = |args: &[&str]| {
2585 let out =
2586 StdCommand::new("git").arg("-C").arg(path).args(args).output().expect("git ok");
2587 assert!(
2588 out.status.success(),
2589 "git {:?} failed in {}: stderr={}",
2590 args,
2591 path.display(),
2592 String::from_utf8_lossy(&out.stderr),
2593 );
2594 };
2595 run(&["init", "-q"]);
2596 run(&["symbolic-ref", "HEAD", &format!("refs/heads/{initial_branch}")]);
2597 run(&["config", "user.email", "test@example.com"]);
2598 run(&["config", "user.name", "Test"]);
2599 run(&["config", "commit.gpgsign", "false"]);
2600 }
2601
2602 fn write_file(dir: &TempDir, name: &str, contents: &str) {
2603 let path = dir.path().join(name);
2604 if let Some(parent) = path.parent() {
2605 std::fs::create_dir_all(parent).expect("mkdir");
2606 }
2607 std::fs::write(&path, contents).expect("write");
2608 }
2609
2610 fn commit_all(dir: &TempDir, message: &str) {
2611 let path = dir.path();
2612 let run = |args: &[&str]| {
2613 StdCommand::new("git").arg("-C").arg(path).args(args).output().expect("git ok");
2614 };
2615 run(&["add", "-A"]);
2616 run(&["commit", "-q", "-m", message]);
2617 }
2618
2619 #[test]
2620 fn parse_numstat_handles_added_removed() {
2621 let raw = "12\t3\tsrc/foo.rs\n5\t0\tsrc/bar.rs\n";
2622 let parsed = parse_numstat(raw);
2623 assert_eq!(parsed.len(), 2);
2624 assert_eq!(parsed[0].path, "src/foo.rs");
2625 assert_eq!(parsed[0].added, 12);
2626 assert_eq!(parsed[0].removed, 3);
2627 assert_eq!(parsed[1].added, 5);
2628 assert_eq!(parsed[1].removed, 0);
2629 }
2630
2631 #[test]
2632 fn parse_numstat_resolves_rename_paths_to_the_new_name() {
2633 let raw = "0\t0\tdir/{a.txt => b.txt}\n1\t1\tplain.txt => renamed.txt\n3\t0\tkept.txt\n";
2637 let parsed = parse_numstat(raw);
2638 assert_eq!(parsed[0].path, "dir/b.txt", "the brace form resolves to the new path");
2639 assert_eq!(parsed[1].path, "renamed.txt", "the bare form takes the right side");
2640 assert_eq!(parsed[2].path, "kept.txt", "a path without a rename is its own");
2641 }
2642
2643 #[test]
2644 fn parse_numstat_skips_binary_files() {
2645 let raw = "-\t-\tbin/blob.png\n4\t1\tsrc/foo.rs\n";
2646 let parsed = parse_numstat(raw);
2647 assert_eq!(parsed.len(), 1);
2648 assert_eq!(parsed[0].path, "src/foo.rs");
2649 }
2650
2651 #[test]
2652 fn parse_numstat_handles_malformed_lines() {
2653 let raw = "12 missing tabs\n\n5\tnope\n4\t1\tsrc/foo.rs\n";
2654 let parsed = parse_numstat(raw);
2655 assert_eq!(parsed.len(), 1);
2656 assert_eq!(parsed[0].path, "src/foo.rs");
2657 }
2658
2659 #[test]
2660 fn parse_numstat_keeps_tabs_inside_path_via_splitn() {
2661 let raw = "4\t1\tsrc/weird\tpath.rs\n";
2664 let parsed = parse_numstat(raw);
2665 assert_eq!(parsed.len(), 1);
2666 assert_eq!(parsed[0].path, "src/weird\tpath.rs");
2667 }
2668
2669 #[tokio::test(flavor = "current_thread")]
2675 async fn scan_no_repo_collapses_to_not_in_repo() {
2676 let dir = tempfile::tempdir().expect("tempdir");
2677 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2678 assert_eq!(snap.repo_gate, RepoGate::NotARepo);
2679 assert!(matches!(snap.worktree, LayerState::Clean));
2680 assert!(matches!(snap.branch_ahead, LayerState::Clean));
2681 assert!(snap.default_branch.is_none());
2682 }
2683
2684 #[tokio::test(flavor = "current_thread")]
2695 async fn the_scans_git_commands_take_the_budget_they_are_given() {
2696 let dir = tempfile::tempdir().expect("tempdir");
2697 init_repo(&dir, "feature");
2698 write_file(&dir, "README.md", "one\n");
2701 commit_all(&dir, "seed");
2702
2703 let bin = tempfile::tempdir().expect("tempdir");
2706 stall_git_at(dir.path(), bin.path());
2707
2708 let refused = scan_within(
2709 dir.path(),
2710 &HashMap::new(),
2711 None,
2712 None,
2713 PrRefresh::Fetch,
2714 true,
2715 Duration::ZERO,
2716 )
2717 .await;
2718 assert_eq!(
2719 refused.repo_gate,
2720 RepoGate::ScannerFailed,
2721 "a budget that runs out before git answers reads as a failed scan",
2722 );
2723
2724 let later = numstat(dir.path(), &["diff", "--numstat", "HEAD"], Duration::ZERO).await;
2728 assert!(
2729 matches!(later, Err(NumstatError::Subprocess)),
2730 "a later helper refuses a zero budget too: {later:?}",
2731 );
2732
2733 crate::env::git_command::clear_git_point(dir.path());
2734 let scanned = scan_within(
2735 dir.path(),
2736 &HashMap::new(),
2737 None,
2738 None,
2739 PrRefresh::Fetch,
2740 true,
2741 TEST_GIT_BUDGET,
2742 )
2743 .await;
2744 assert_eq!(scanned.repo_gate, RepoGate::InRepo, "and the tests' budget scans the repo");
2745 assert!(
2746 matches!(scanned.branch, GitBranch::Named(ref name) if name == "feature"),
2747 "with the branch git reported: {:?}",
2748 scanned.branch,
2749 );
2750 assert!(
2751 TEST_GIT_BUDGET >= Duration::from_secs(30),
2752 "the tests' budget must stay far past the shell's own 10 s: a loaded machine held a \
2753 lookup child back for 10.5 s (measured 2026-10-14), the stall this seam exists to \
2754 keep out of the suite",
2755 );
2756 }
2757
2758 #[tokio::test(flavor = "current_thread")]
2759 async fn current_branch_reports_named_branch() {
2760 let dir = tempfile::tempdir().expect("tempdir");
2761 init_repo(&dir, "feat/x");
2762 write_file(&dir, "README.md", "hi\n");
2763 commit_all(&dir, "init");
2764 assert_eq!(current_branch(dir.path()).await, Ok(Some("feat/x".to_owned())));
2765 }
2766
2767 #[tokio::test(flavor = "current_thread")]
2768 async fn current_branch_is_ok_none_on_detached_head() {
2769 let dir = tempfile::tempdir().expect("tempdir");
2770 init_repo(&dir, "main");
2771 write_file(&dir, "README.md", "hi\n");
2772 commit_all(&dir, "init");
2773 let out = StdCommand::new("git")
2774 .arg("-C")
2775 .arg(dir.path())
2776 .args(["checkout", "--detach"])
2777 .output()
2778 .expect("git ok");
2779 assert!(out.status.success(), "detach: {}", String::from_utf8_lossy(&out.stderr));
2780 assert_eq!(
2781 current_branch(dir.path()).await,
2782 Ok(None),
2783 "a detached HEAD is a repo with no branch name, not a failed read",
2784 );
2785 }
2786
2787 #[tokio::test(flavor = "current_thread")]
2793 async fn current_branch_reports_not_a_repo_outside_a_repo() {
2794 let dir = tempfile::tempdir().expect("tempdir");
2795 assert_eq!(current_branch(dir.path()).await, Err(RepoGate::NotARepo));
2796 }
2797
2798 #[tokio::test(flavor = "current_thread")]
2803 async fn current_branch_reports_not_a_repo_for_a_missing_directory() {
2804 let dir = tempfile::tempdir().expect("tempdir");
2805 assert_eq!(current_branch(&dir.path().join("gone")).await, Err(RepoGate::NotARepo));
2806 }
2807
2808 #[tokio::test(flavor = "current_thread")]
2812 async fn current_branch_on_an_unborn_head_is_not_reported_as_a_non_repo() {
2813 let dir = tempfile::tempdir().expect("tempdir");
2814 init_repo(&dir, "main");
2815 assert_eq!(
2816 current_branch(dir.path()).await,
2817 Err(RepoGate::ScannerFailed),
2818 "a repo with no commits is still a repo, so it must not be suppressed as NotARepo",
2819 );
2820 }
2821
2822 #[tokio::test(flavor = "current_thread")]
2834 async fn git_layer_answers_from_cwd_not_an_exported_git_dir() {
2835 const PROBE: &str = "FORGE_SCRUB_PROBE";
2836 const PROBE_OK: &str = "FORGE_SCRUB_PROBE_OK";
2837 const PROBE_TEST: &str =
2838 "env::git_diff::tests::git_layer_answers_from_cwd_not_an_exported_git_dir";
2839
2840 let foreign = tempfile::tempdir().expect("foreign tempdir");
2841 let host = tempfile::tempdir().expect("host tempdir");
2842 let plain = tempfile::tempdir().expect("plain tempdir");
2843 init_repo(&foreign, "exported-only-branch");
2844 write_file(&foreign, "README.md", "foreign\n");
2845 commit_all(&foreign, "foreign init");
2846 init_repo(&host, "cwd-repo-branch");
2847 write_file(&host, "README.md", "host\n");
2848 commit_all(&host, "host init");
2849
2850 if std::env::var(PROBE).is_err() {
2851 let out = StdCommand::new(std::env::current_exe().expect("current_exe"))
2854 .args(["--exact", PROBE_TEST, "--test-threads=1", "--nocapture"])
2855 .env(PROBE, "1")
2856 .env("GIT_DIR", foreign.path().join(".git"))
2857 .env("GIT_WORK_TREE", foreign.path())
2858 .env("GIT_COMMON_DIR", foreign.path().join(".git"))
2859 .env("FORGE_SCRUB_HOST", host.path())
2860 .env("FORGE_SCRUB_PLAIN", plain.path())
2861 .output()
2862 .expect("spawn probe");
2863 assert!(
2864 out.status.success(),
2865 "probe failed under exported GIT_DIR: {}",
2866 String::from_utf8_lossy(&out.stdout),
2867 );
2868 assert!(
2869 String::from_utf8_lossy(&out.stdout).contains(PROBE_OK),
2870 "the probe never ran (filter matched nothing?)",
2871 );
2872 return;
2873 }
2874
2875 let host = PathBuf::from(std::env::var("FORGE_SCRUB_HOST").expect("host path"));
2879 let plain = PathBuf::from(std::env::var("FORGE_SCRUB_PLAIN").expect("plain path"));
2880
2881 assert_eq!(
2882 repo_presence(&plain).await,
2883 RepoPresence::Absent,
2884 "a plain dir with GIT_DIR exported must not answer as a repo",
2885 );
2886 assert!(
2887 !crate::env::worktree::is_git_repo(&plain),
2888 "is_git_repo must not inherit the exported repo",
2889 );
2890 let GitOutput::Ok(branch) = run_git(&host, &["rev-parse", "--abbrev-ref", "HEAD"]).await
2891 else {
2892 panic!("run_git failed against a live repo");
2893 };
2894 assert_eq!(
2895 branch.trim(),
2896 "cwd-repo-branch",
2897 "run_git must answer from -C, not the exported GIT_DIR",
2898 );
2899 println!("{PROBE_OK}");
2900 }
2901
2902 #[tokio::test(flavor = "current_thread")]
2903 async fn scan_clean_default_branch_has_no_layers() {
2904 let dir = tempfile::tempdir().expect("tempdir");
2905 init_repo(&dir, "main");
2906 write_file(&dir, "README.md", "hello\n");
2907 commit_all(&dir, "init");
2908 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2909 assert_eq!(snap.repo_gate, RepoGate::InRepo);
2910 assert!(matches!(snap.worktree, LayerState::Clean), "clean tree → no layer 1");
2911 assert!(matches!(snap.branch_ahead, LayerState::Clean), "on default → no layer 2");
2912 assert_eq!(snap.default_branch.as_deref(), Some("main"));
2913 }
2914
2915 #[tokio::test(flavor = "current_thread")]
2921 async fn a_fetch_scan_stamps_an_attempt_when_there_is_no_pr() {
2922 let dir = tempfile::tempdir().expect("tempdir");
2923 init_repo(&dir, "feature");
2924 write_file(&dir, "README.md", "hello\n");
2925 commit_all(&dir, "init");
2926 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2927 assert!(
2928 matches!(snap.branch, GitBranch::Named(ref name) if name == "feature"),
2929 "the scan reads the feature branch",
2930 );
2931 assert_eq!(snap.pr, None, "an unpushed branch has no PR to show");
2932 assert!(snap.pr_fetched_at.is_some(), "but the attempt is stamped");
2933 assert!(
2934 !pr_refresh_due(&snap, std::time::SystemTime::now()),
2935 "so the cache is not due on every poke",
2936 );
2937 }
2938
2939 #[tokio::test(flavor = "current_thread")]
2940 async fn scan_dirty_default_branch_populates_worktree_only() {
2941 let dir = tempfile::tempdir().expect("tempdir");
2942 init_repo(&dir, "main");
2943 write_file(&dir, "README.md", "first\n");
2944 commit_all(&dir, "init");
2945 write_file(&dir, "README.md", "second\nthird\n");
2947 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2948 let LayerState::Populated(stats) = &snap.worktree else {
2949 panic!("layer 1 populated on dirty tree");
2950 };
2951 assert!(
2952 matches!(snap.branch_ahead, LayerState::Clean),
2953 "on default branch → layer 2 stays empty"
2954 );
2955 assert_eq!(stats.total_files, 1);
2956 assert_eq!(stats.files[0].path, "README.md");
2957 assert!(stats.files[0].added >= 1);
2958 assert_eq!(stats.total_added, stats.files[0].added);
2959 assert_eq!(stats.total_removed, stats.files[0].removed);
2960 }
2961
2962 #[tokio::test(flavor = "current_thread")]
2963 async fn scan_clean_feature_branch_populates_branch_ahead_only() {
2964 let dir = tempfile::tempdir().expect("tempdir");
2965 init_repo(&dir, "main");
2966 write_file(&dir, "README.md", "first\n");
2967 commit_all(&dir, "init");
2968 let run = |args: &[&str]| {
2969 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
2970 };
2971 run(&["checkout", "-q", "-b", "feat/x"]);
2972 write_file(&dir, "feat.rs", "fn x() {}\n");
2973 commit_all(&dir, "feat commit");
2974
2975 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2976 assert!(matches!(snap.worktree, LayerState::Clean), "clean tree → no layer 1");
2977 let LayerState::Populated(ahead) = &snap.branch_ahead else {
2978 panic!("layer 2 populated on feature branch");
2979 };
2980 assert_eq!(ahead.commit_count, 1, "one commit on feature branch beyond main");
2981 assert_eq!(ahead.stats.total_files, 1);
2982 assert_eq!(ahead.stats.files[0].path, "feat.rs");
2983 assert_eq!(snap.default_branch.as_deref(), Some("main"));
2984 assert_eq!(ahead.stats.total_added, ahead.stats.files[0].added);
2985 assert_eq!(ahead.stats.total_removed, ahead.stats.files[0].removed);
2986 }
2987
2988 #[tokio::test(flavor = "current_thread")]
2992 async fn scan_dirty_feature_branch_populates_both_layers() {
2993 let dir = tempfile::tempdir().expect("tempdir");
2994 init_repo(&dir, "main");
2995 write_file(&dir, "README.md", "first\n");
2996 commit_all(&dir, "init");
2997 let run = |args: &[&str]| {
2998 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
2999 };
3000 run(&["checkout", "-q", "-b", "feat/x"]);
3001 write_file(&dir, "feat.rs", "fn x() {}\n");
3002 commit_all(&dir, "feat commit");
3003 write_file(&dir, "feat.rs", "fn x() {}\nfn y() {}\n");
3005
3006 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3007 let LayerState::Populated(worktree) = &snap.worktree else {
3008 panic!("layer 1 populated on dirty tree");
3009 };
3010 let LayerState::Populated(ahead) = &snap.branch_ahead else {
3011 panic!("layer 2 populated on feature branch");
3012 };
3013 assert_eq!(worktree.total_files, 1);
3015 assert_eq!(worktree.files[0].path, "feat.rs");
3016 assert_eq!(ahead.commit_count, 1);
3019 assert_eq!(ahead.stats.total_files, 1);
3020 assert_eq!(ahead.stats.files[0].path, "feat.rs");
3021 assert_eq!(
3022 ahead.stats.files[0].status,
3023 forge_primitives::git::FileStatus::Added,
3024 "the range's added file wears Added, not the numstat default"
3025 );
3026 assert_eq!(ahead.commits.len(), 1, "the chain is the one feature commit");
3028 assert_eq!(ahead.commits[0].stats.files[0].path, "feat.rs", "the commit's own file");
3029 assert_eq!(
3030 ahead.commits[0].stats.files[0].status,
3031 forge_primitives::git::FileStatus::Added,
3032 "with the commit's own mark"
3033 );
3034 assert_eq!(ahead.commits[0].stats.files[0].added, 1, "and the commit's own count");
3035 assert!(ahead.commits[0].time > 0, "the commit carries its instant");
3036 }
3037
3038 #[tokio::test(flavor = "current_thread")]
3039 async fn scan_detached_dirty_populates_worktree_no_branch_ahead() {
3040 let dir = tempfile::tempdir().expect("tempdir");
3041 init_repo(&dir, "main");
3042 write_file(&dir, "README.md", "first\n");
3043 commit_all(&dir, "init");
3044 write_file(&dir, "README.md", "second\n");
3045 commit_all(&dir, "second");
3046 let run = |args: &[&str]| {
3047 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3048 };
3049 run(&["checkout", "-q", "HEAD~1"]);
3050 write_file(&dir, "README.md", "third\n");
3052
3053 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3054 assert!(snap.worktree.is_populated(), "detached + dirty → layer 1 populated");
3055 assert!(
3056 matches!(snap.branch_ahead, LayerState::Clean),
3057 "detached HEAD has no branch name, layer 2 stays empty"
3058 );
3059 }
3060
3061 #[tokio::test(flavor = "current_thread")]
3062 async fn scan_detached_clean_has_no_layers() {
3063 let dir = tempfile::tempdir().expect("tempdir");
3064 init_repo(&dir, "main");
3065 write_file(&dir, "README.md", "first\n");
3066 commit_all(&dir, "init");
3067 write_file(&dir, "README.md", "second\n");
3068 commit_all(&dir, "second");
3069 let run = |args: &[&str]| {
3070 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3071 };
3072 run(&["checkout", "-q", "HEAD~1"]);
3073 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3074 assert_eq!(snap.repo_gate, RepoGate::InRepo);
3075 assert!(matches!(snap.worktree, LayerState::Clean));
3076 assert!(matches!(snap.branch_ahead, LayerState::Clean));
3077 assert_eq!(snap.pr, None, "detached HEAD resolves no PR row");
3078 assert_eq!(snap.pushed_sha, None, "the pushed-sha walk is skipped when detached");
3079 }
3080
3081 #[tokio::test(flavor = "current_thread")]
3086 async fn pushed_sha_walk_resolves_pushed_ancestor_when_upstream_diverged() {
3087 let dir = tempfile::tempdir().expect("tempdir");
3088 init_repo(&dir, "audit/x");
3089 write_file(&dir, "README.md", "one\n");
3090 commit_all(&dir, "pushed commit");
3091 let pushed = rev_parse_head(&dir);
3092 let run = |args: &[&str]| {
3093 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3094 };
3095 run(&["update-ref", "refs/remotes/origin/audit/x", &pushed]);
3096 let out = StdCommand::new("git")
3099 .arg("-C")
3100 .arg(dir.path())
3101 .args(["commit-tree", "HEAD^{tree}", "-p", &pushed, "-m", "remote-only"])
3102 .output()
3103 .expect("git ok");
3104 assert!(out.status.success(), "commit-tree failed");
3105 let remote_only = String::from_utf8_lossy(&out.stdout).trim().to_owned();
3106 run(&["update-ref", "refs/remotes/origin/main", &remote_only]);
3107 write_file(&dir, "more.rs", "y\n");
3108 commit_all(&dir, "unpushed commit");
3109
3110 assert_eq!(
3111 resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await.as_deref(),
3112 Some(pushed.as_str()),
3113 "the sibling remote-only commit is outside HEAD's ancestry and must not resolve"
3114 );
3115 }
3116
3117 #[test]
3121 fn pr_cache_fresh_refetches_a_cached_none_when_stale() {
3122 let old = std::time::SystemTime::now()
3123 .checked_sub(PR_REFETCH_INTERVAL + std::time::Duration::from_secs(1))
3124 .expect("wall clock is at least PR_REFETCH_INTERVAL + 1s past boot");
3125 let prev = GitDiffSnapshot {
3126 branch: GitBranch::Named("feat/x".into()),
3127 default_branch: Some("main".into()),
3128 repo_gate: RepoGate::InRepo,
3129 pushed_sha: Some("aaaa".into()),
3130 worktree: LayerState::Clean,
3131 branch_ahead: LayerState::Clean,
3132 pr: None,
3133 closes: Vec::new(),
3134 pr_fetched_at: Some(old),
3135 };
3136 assert!(
3137 !pr_cache_fresh(Some(&prev), "feat/x", Some("aaaa"), std::time::SystemTime::now()),
3138 "stale None must refetch so a PR opened after the first scan shows up"
3139 );
3140 }
3141
3142 #[tokio::test(flavor = "current_thread")]
3146 async fn scan_on_an_unborn_head_has_no_pr_row() {
3147 let dir = tempfile::tempdir().expect("tempdir");
3148 init_repo(&dir, "main");
3149 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3150 assert_eq!(snap.repo_gate, RepoGate::ScannerFailed);
3151 assert_eq!(snap.pr, None);
3152 assert_eq!(snap.pushed_sha, None);
3153 assert_eq!(snap.pr_fetched_at, None);
3154 }
3155
3156 #[tokio::test(flavor = "current_thread")]
3157 async fn scan_dirty_default_branch_aggregates_totals_across_files() {
3158 let dir = tempfile::tempdir().expect("tempdir");
3159 init_repo(&dir, "main");
3160 write_file(&dir, "a.txt", "1\n2\n3\n");
3161 write_file(&dir, "b.txt", "1\n");
3162 commit_all(&dir, "init");
3163 write_file(&dir, "a.txt", "1\n2\n3\n4\n");
3167 write_file(&dir, "b.txt", "x\ny\nz\n");
3168 write_file(&dir, "c.txt", "new\n");
3169 StdCommand::new("git")
3171 .arg("-C")
3172 .arg(dir.path())
3173 .args(["add", "c.txt"])
3174 .output()
3175 .expect("git ok");
3176
3177 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3178 let LayerState::Populated(stats) = &snap.worktree else {
3179 panic!("dirty tree → layer 1 populated");
3180 };
3181 assert_eq!(stats.total_files, 3);
3182 let per_file_added: u32 = stats.files.iter().map(|f| f.added).sum();
3184 let per_file_removed: u32 = stats.files.iter().map(|f| f.removed).sum();
3185 assert_eq!(stats.total_added, per_file_added);
3186 assert_eq!(stats.total_removed, per_file_removed);
3187 assert!(stats.total_added >= 5);
3190 assert!(stats.total_removed >= 1);
3191 }
3192
3193 #[test]
3194 fn top_files_sort_by_total_changes_then_alpha() {
3195 let mut files = [
3196 GitDiffFile {
3197 path: "small.rs".into(),
3198 added: 1,
3199 removed: 0,
3200 status: FileStatus::Modified,
3201 },
3202 GitDiffFile {
3203 path: "big-b.rs".into(),
3204 added: 50,
3205 removed: 10,
3206 status: FileStatus::Modified,
3207 },
3208 GitDiffFile {
3209 path: "big-a.rs".into(),
3210 added: 50,
3211 removed: 10,
3212 status: FileStatus::Modified,
3213 },
3214 GitDiffFile {
3215 path: "medium.rs".into(),
3216 added: 10,
3217 removed: 5,
3218 status: FileStatus::Modified,
3219 },
3220 ];
3221 files.sort_by(|a, b| {
3222 let a_total = a.added.saturating_add(a.removed);
3223 let b_total = b.added.saturating_add(b.removed);
3224 b_total.cmp(&a_total).then_with(|| a.path.cmp(&b.path))
3225 });
3226 assert_eq!(files[0].path, "big-a.rs");
3227 assert_eq!(files[1].path, "big-b.rs");
3228 assert_eq!(files[2].path, "medium.rs");
3229 assert_eq!(files[3].path, "small.rs");
3230 }
3231
3232 #[test]
3236 fn pr_cache_fresh_reuses_when_branch_sha_and_age_hold() {
3237 let prev = snapshot_with_pr(42);
3238 assert!(pr_cache_fresh(Some(&prev), "feat/x", Some("aaaa"), std::time::SystemTime::now()));
3239 }
3240
3241 #[test]
3245 fn pr_cache_fresh_refetches_when_pushed_sha_changes() {
3246 let prev = snapshot_with_pr(42);
3247 assert!(
3248 !pr_cache_fresh(Some(&prev), "feat/x", Some("bbbb"), std::time::SystemTime::now()),
3249 "sha change must invalidate the cached PR"
3250 );
3251 }
3252
3253 #[test]
3257 fn pr_cache_fresh_refetches_when_fetch_is_stale() {
3258 let prev = snapshot_with_pr(42);
3259 let old = std::time::SystemTime::now()
3260 .checked_sub(PR_REFETCH_INTERVAL + std::time::Duration::from_secs(1))
3261 .expect("monotonic wall clock is at least PR_REFETCH_INTERVAL + 1s past boot");
3262 assert!(
3263 !pr_cache_fresh(Some(&prev), "feat/x", Some("aaaa"), old),
3264 "stale fetch must refetch so a PR opened after the first scan shows up"
3265 );
3266 }
3267
3268 #[tokio::test(flavor = "current_thread")]
3273 async fn serve_cached_answers_nothing_for_another_branch_or_sha() {
3274 let dir = tempfile::tempdir().expect("tempdir");
3275 let prev = snapshot_with_pr_at(42, Some(600));
3276 let budget = TEST_GIT_BUDGET;
3277
3278 let (pr, closes, at) = pr_for_head_within(
3279 dir.path(),
3280 &HashMap::new(),
3281 "feat/y",
3282 Some("aaaa"),
3283 Some(&prev),
3284 PrRefresh::ServeCached,
3285 budget,
3286 )
3287 .await;
3288 assert_eq!(pr, None, "another branch's PR is not this row's");
3289 assert!(closes.is_empty(), "nor are its closing issues");
3290 assert_eq!(at, None, "and no attempt time is claimed for it");
3291
3292 let (pr, _, at) = pr_for_head_within(
3293 dir.path(),
3294 &HashMap::new(),
3295 "feat/x",
3296 Some("bbbb"),
3297 Some(&prev),
3298 PrRefresh::ServeCached,
3299 budget,
3300 )
3301 .await;
3302 assert_eq!(pr, None, "another push's PR is not this row's");
3303 assert_eq!(at, None, "and no attempt time is claimed for it");
3304
3305 let (pr, _, at) = pr_for_head_within(
3306 dir.path(),
3307 &HashMap::new(),
3308 "feat/x",
3309 Some("aaaa"),
3310 Some(&prev),
3311 PrRefresh::ServeCached,
3312 budget,
3313 )
3314 .await;
3315 assert_eq!(pr.map(|found| found.number), Some(42), "its own PR is served");
3316 assert_eq!(at, prev.pr_fetched_at, "with the attempt time the store holds");
3317 }
3318
3319 #[test]
3325 fn pr_refresh_due_follows_the_scans_own_eligibility() {
3326 let fresh = snapshot_with_pr(42);
3329 let stale = snapshot_with_pr_at(42, Some(600));
3330 let mut on_default = snapshot_with_pr_at(42, None);
3331 on_default.branch = GitBranch::Named("main".into());
3332 let now = std::time::SystemTime::now();
3333
3334 assert!(pr_refresh_due(&stale, now), "a stale attempt on a feature branch is due");
3335 assert!(!pr_refresh_due(&fresh, now), "an attempt inside the window is not due");
3336 assert!(
3337 !pr_refresh_due(&on_default, now),
3338 "a branch checked out on the default has nothing to look up",
3339 );
3340 assert!(
3343 pr_refresh_due(&snapshot_with_pr_at(42, None), now),
3344 "a feature branch never attempted is due",
3345 );
3346 }
3347
3348 #[tokio::test(flavor = "current_thread")]
3353 async fn pr_for_head_reuses_prev_when_cache_fresh() {
3354 let dir = tempfile::tempdir().expect("tempdir");
3355 let pr = GitPrInfo { number: 42, url: "https://example/pull/42".into(), draft: false };
3356 let closes = vec![GitIssueRef { number: 7, url: "https://example/issues/7".into() }];
3357 let prev = GitDiffSnapshot {
3358 branch: GitBranch::Named("feat/x".into()),
3359 default_branch: Some("main".into()),
3360 repo_gate: RepoGate::InRepo,
3361 pushed_sha: Some("aaaa".into()),
3362 worktree: LayerState::Clean,
3363 branch_ahead: LayerState::Clean,
3364 pr: Some(pr.clone()),
3365 closes: closes.clone(),
3366 pr_fetched_at: Some(std::time::SystemTime::now()),
3367 };
3368
3369 let (got_pr, got_closes, got_at) = pr_for_head_within(
3370 dir.path(),
3371 &HashMap::new(),
3372 "feat/x",
3373 Some("aaaa"),
3374 Some(&prev),
3375 PrRefresh::Fetch,
3376 COMMAND_TIMEOUT,
3377 )
3378 .await;
3379 assert_eq!(got_pr, Some(pr));
3380 assert_eq!(got_closes, closes);
3381 assert_eq!(got_at, prev.pr_fetched_at);
3382 }
3383
3384 #[tokio::test(flavor = "current_thread")]
3389 async fn pr_for_head_keeps_cached_pr_when_refetch_fails() {
3390 let dir = tempfile::tempdir().expect("tempdir");
3391 let stale_at = std::time::SystemTime::now()
3392 .checked_sub(PR_REFETCH_INTERVAL + std::time::Duration::from_secs(1))
3393 .expect("wall clock is at least PR_REFETCH_INTERVAL + 1s past boot");
3394 let prev = GitDiffSnapshot {
3395 branch: GitBranch::Named("feat/x".into()),
3396 default_branch: Some("main".into()),
3397 repo_gate: RepoGate::InRepo,
3398 pushed_sha: Some("aaaa".into()),
3399 worktree: LayerState::Clean,
3400 branch_ahead: LayerState::Clean,
3401 pr: Some(GitPrInfo { number: 42, url: "https://example/pull/42".into(), draft: false }),
3402 closes: Vec::new(),
3403 pr_fetched_at: Some(stale_at),
3404 };
3405
3406 let (got_pr, _got_closes, got_at) = pr_for_head_within(
3407 dir.path(),
3408 &HashMap::new(),
3409 "feat/x",
3410 Some("aaaa"),
3411 Some(&prev),
3412 PrRefresh::Fetch,
3413 COMMAND_TIMEOUT,
3414 )
3415 .await;
3416 assert_eq!(got_pr.as_ref().map(|p| p.number), Some(42), "failed refetch keeps the row");
3417 let now = std::time::SystemTime::now();
3418 assert!(
3419 got_at.is_some_and(|at| now.duration_since(at).is_ok_and(|d| d.as_secs() < 60)),
3420 "the refetch attempt must be stamped so the next retry waits for the interval"
3421 );
3422 }
3423
3424 #[tokio::test(flavor = "current_thread")]
3428 async fn pr_for_head_clears_failed_refetch_when_branch_changed() {
3429 let dir = tempfile::tempdir().expect("tempdir");
3430 let stale_at = std::time::SystemTime::now()
3431 .checked_sub(PR_REFETCH_INTERVAL + std::time::Duration::from_secs(1))
3432 .expect("wall clock is at least PR_REFETCH_INTERVAL + 1s past boot");
3433 let prev = GitDiffSnapshot {
3434 branch: GitBranch::Named("feat/x".into()),
3435 default_branch: Some("main".into()),
3436 repo_gate: RepoGate::InRepo,
3437 pushed_sha: Some("aaaa".into()),
3438 worktree: LayerState::Clean,
3439 branch_ahead: LayerState::Clean,
3440 pr: Some(GitPrInfo { number: 42, url: "https://example/pull/42".into(), draft: false }),
3441 closes: Vec::new(),
3442 pr_fetched_at: Some(stale_at),
3443 };
3444
3445 let (got_pr, got_closes, got_at) = pr_for_head_within(
3446 dir.path(),
3447 &HashMap::new(),
3448 "feat/y",
3449 Some("aaaa"),
3450 Some(&prev),
3451 PrRefresh::Fetch,
3452 COMMAND_TIMEOUT,
3453 )
3454 .await;
3455 assert_eq!(got_pr, None, "a failed lookup must not show another branch's PR");
3456 assert!(got_closes.is_empty());
3457 assert!(got_at.is_some());
3458 }
3459
3460 #[tokio::test(flavor = "current_thread")]
3468 async fn the_pr_lookup_spawn_carries_the_projects_env() {
3469 let recorded = scan_with_own_gh(Duration::ZERO, TEST_LOOKUP_BUDGET)
3470 .await
3471 .expect("the scan's gh child ran and recorded the identity it was handed");
3472 assert_eq!(recorded, "project-token", "the PR lookup spawns gh as the project");
3473 }
3474
3475 #[tokio::test(flavor = "current_thread")]
3481 async fn the_pr_lookup_is_bounded_by_the_budget_it_is_given() {
3482 let refused = scan_with_own_gh(Duration::from_secs(3), Duration::from_secs(1)).await;
3483 assert!(
3484 refused.is_none(),
3485 "a budget that runs out before the child answers leaves the lookup unrun: {refused:?}",
3486 );
3487
3488 let waited = scan_with_own_gh(Duration::from_secs(3), TEST_LOOKUP_BUDGET).await;
3489 assert_eq!(
3490 waited.as_deref(),
3491 Some("project-token"),
3492 "and the tests' budget waits that child out",
3493 );
3494 assert!(
3495 TEST_LOOKUP_BUDGET >= Duration::from_secs(30),
3496 "the tests' budget must stay far past the shell's own 10 s: a loaded machine held a \
3497 lookup child back for 10.5 s (measured 2026-10-14), which is the stall this seam \
3498 exists to keep out of the suite",
3499 );
3500 }
3501
3502 async fn scan_with_own_gh(stall: Duration, budget: Duration) -> Option<String> {
3511 use std::os::unix::fs::PermissionsExt;
3512
3513 let dir = tempfile::tempdir().expect("tempdir");
3514 init_repo(&dir, "feature");
3515 let run = |args: &[&str]| {
3516 let out =
3517 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git");
3518 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
3519 };
3520 write_file(&dir, "README.md", "one\n");
3521 commit_all(&dir, "pushed commit");
3522 run(&["update-ref", "refs/remotes/origin/main", "HEAD"]);
3526 run(&["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
3527
3528 let bin = dir.path().join("bin");
3529 std::fs::create_dir_all(&bin).expect("bin dir");
3530 let seen = dir.path().join("gh-seen-identity");
3531 let gh = bin.join("gh");
3532 let hold = if stall.is_zero() {
3533 String::new()
3534 } else {
3535 format!("/bin/sleep {}\n", stall.as_secs_f64())
3536 };
3537 std::fs::write(
3538 &gh,
3539 format!(
3540 "#!/bin/sh\n{hold}printf '%s' \"$GH_TOKEN\" > {}\nprintf '[]'\n",
3541 seen.display()
3542 ),
3543 )
3544 .expect("write gh");
3545 std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).expect("chmod gh");
3546 let env = HashMap::from([
3547 ("PATH".to_owned(), bin.display().to_string()),
3548 ("GH_TOKEN".to_owned(), "project-token".to_owned()),
3549 ]);
3550
3551 scan_within(dir.path(), &env, None, None, PrRefresh::Fetch, true, budget).await;
3552 std::fs::read_to_string(&seen).ok()
3553 }
3554
3555 #[tokio::test(flavor = "current_thread")]
3562 async fn pushed_sha_walk_resolves_pushed_ancestor_when_worktree_is_ahead() {
3563 let dir = tempfile::tempdir().expect("tempdir");
3564 init_repo(&dir, "worktree-inspector-pr-row");
3565 write_file(&dir, "README.md", "one\n");
3566 commit_all(&dir, "pushed commit");
3567 let pushed = rev_parse_head(&dir);
3568 let run = |args: &[&str]| {
3569 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3570 };
3571 run(&["update-ref", "refs/remotes/origin/audit/2026-09-03", &pushed]);
3575 write_file(&dir, "more.rs", "fn y() {}\n");
3576 commit_all(&dir, "unpushed commit");
3577 write_file(&dir, "more2.rs", "fn z() {}\n");
3578 commit_all(&dir, "unpushed commit 2");
3579
3580 assert_eq!(
3581 resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await.as_deref(),
3582 Some(pushed.as_str()),
3583 "the walk must stop at the pushed ancestor, not the unpushed tip"
3584 );
3585 }
3586
3587 #[tokio::test(flavor = "current_thread")]
3590 async fn pushed_sha_walk_resolves_tip_when_pushed() {
3591 let dir = tempfile::tempdir().expect("tempdir");
3592 init_repo(&dir, "main");
3593 write_file(&dir, "README.md", "one\n");
3594 commit_all(&dir, "pushed commit");
3595 let pushed = rev_parse_head(&dir);
3596 StdCommand::new("git")
3597 .arg("-C")
3598 .arg(dir.path())
3599 .args(["update-ref", "refs/remotes/origin/main", &pushed])
3600 .output()
3601 .expect("git ok");
3602
3603 assert_eq!(
3604 resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await.as_deref(),
3605 Some(pushed.as_str())
3606 );
3607 }
3608
3609 #[tokio::test(flavor = "current_thread")]
3612 async fn pushed_sha_walk_returns_none_when_nothing_pushed() {
3613 let dir = tempfile::tempdir().expect("tempdir");
3614 init_repo(&dir, "main");
3615 write_file(&dir, "README.md", "one\n");
3616 commit_all(&dir, "local commit");
3617
3618 assert_eq!(resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await, None);
3619 }
3620
3621 #[tokio::test(flavor = "current_thread")]
3626 async fn pushed_sha_walk_caps_at_max_count() {
3627 let dir = tempfile::tempdir().expect("tempdir");
3628 init_repo(&dir, "main");
3629 write_file(&dir, "README.md", "one\n");
3630 commit_all(&dir, "pushed commit");
3631 let pushed = rev_parse_head(&dir);
3632 StdCommand::new("git")
3633 .arg("-C")
3634 .arg(dir.path())
3635 .args(["update-ref", "refs/remotes/origin/main", &pushed])
3636 .output()
3637 .expect("git ok");
3638 for i in 0..4 {
3639 write_file(&dir, &format!("f{i}.rs"), "x\n");
3640 commit_all(&dir, &format!("unpushed {i}"));
3641 }
3642
3643 assert_eq!(resolve_pushed_sha(dir.path(), 2, TEST_GIT_BUDGET).await, None);
3646 assert_eq!(
3648 resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await.as_deref(),
3649 Some(pushed.as_str())
3650 );
3651 }
3652
3653 #[test]
3658 fn pick_open_pr_filters_to_open_and_picks_newest_updated_at() {
3659 let raw = concat!(
3660 r#"[{"number":900,"state":"closed","html_url":"https://example/pull/900","updated_at":"2026-09-03T15:00:00Z"},"#,
3661 r#"{"number":858,"state":"open","html_url":"https://example/pull/858","updated_at":"2026-09-03T10:00:00Z"},"#,
3662 r#"{"number":859,"state":"open","html_url":"https://example/pull/859","updated_at":"2026-09-03T12:00:00Z"}]"#
3663 );
3664 let picked = pick_open_pr(raw).expect("parses");
3665 assert_eq!(
3666 picked,
3667 Some((859, "https://example/pull/859".to_owned(), false)),
3668 "open filter first, then most recently updated wins"
3669 );
3670 }
3671
3672 #[test]
3675 fn pick_open_pr_ties_break_on_higher_number() {
3676 let raw = concat!(
3677 r#"[{"number":401,"state":"open","html_url":"https://example/pull/401","updated_at":"2026-09-03T12:00:00Z"},"#,
3678 r#"{"number":402,"state":"open","html_url":"https://example/pull/402","updated_at":"2026-09-03T12:00:00Z"}]"#
3679 );
3680 assert_eq!(
3681 pick_open_pr(raw).expect("parses"),
3682 Some((402, "https://example/pull/402".to_owned(), false))
3683 );
3684 }
3685
3686 #[test]
3689 fn pick_open_pr_returns_none_when_only_closed_prs() {
3690 let raw = r#"[{"number":857,"state":"closed","html_url":"https://example/pull/857","updated_at":"2026-09-03T13:16:33Z"}]"#;
3691 assert_eq!(pick_open_pr(raw).expect("parses"), None);
3692 }
3693
3694 #[test]
3697 fn pick_open_pr_reports_parse_error() {
3698 assert!(pick_open_pr("not json").is_err());
3699 }
3700
3701 fn snapshot_with_pr(number: u64) -> GitDiffSnapshot {
3702 snapshot_with_pr_at(number, Some(0))
3703 }
3704
3705 fn snapshot_with_pr_at(number: u64, fetched_secs_ago: Option<u64>) -> GitDiffSnapshot {
3706 let pr = GitPrInfo { number, url: format!("https://example/pull/{number}"), draft: false };
3707 let pr_fetched_at = fetched_secs_ago
3708 .map(|secs| std::time::SystemTime::now() - std::time::Duration::from_secs(secs));
3709 GitDiffSnapshot {
3710 branch: GitBranch::Named("feat/x".into()),
3711 default_branch: Some("main".into()),
3712 repo_gate: RepoGate::InRepo,
3713 pushed_sha: Some("aaaa".into()),
3714 worktree: LayerState::Clean,
3715 branch_ahead: LayerState::Clean,
3716 pr: Some(pr),
3717 closes: Vec::new(),
3718 pr_fetched_at,
3719 }
3720 }
3721
3722 fn rev_parse_head(dir: &TempDir) -> String {
3724 rev_parse(dir.path(), "HEAD")
3725 }
3726
3727 fn rev_parse(dir: &Path, rev: &str) -> String {
3729 let out = StdCommand::new("git")
3730 .arg("-C")
3731 .arg(dir)
3732 .args(["rev-parse", rev])
3733 .output()
3734 .expect("git ok");
3735 assert!(out.status.success(), "rev-parse {rev} failed in {}", dir.display());
3736 String::from_utf8_lossy(&out.stdout).trim().to_owned()
3737 }
3738
3739 fn git_at(dir: &Path, args: &[&str]) {
3741 let out = StdCommand::new("git").arg("-C").arg(dir).args(args).output().expect("git ok");
3742 assert!(
3743 out.status.success(),
3744 "git {args:?} failed in {}: {}",
3745 dir.display(),
3746 String::from_utf8_lossy(&out.stderr),
3747 );
3748 }
3749
3750 #[tokio::test(flavor = "current_thread")]
3757 async fn scan_carries_prev_pr_through_when_branch_unchanged() {
3758 let dir = tempfile::tempdir().expect("tempdir");
3759 init_repo(&dir, "main");
3760 write_file(&dir, "README.md", "first\n");
3761 commit_all(&dir, "init");
3762 let run = |args: &[&str]| {
3763 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3764 };
3765 run(&["checkout", "-q", "-b", "feat/cache"]);
3766 write_file(&dir, "feat.rs", "fn x() {}\n");
3767 commit_all(&dir, "feat commit");
3768
3769 let synthetic_pr =
3770 GitPrInfo { number: 99, url: "https://example/pull/99".into(), draft: false };
3771 let synthetic_closes =
3772 vec![GitIssueRef { number: 1, url: "https://example/issues/1".into() }];
3773 let prev = GitDiffSnapshot {
3774 branch: GitBranch::Named("feat/cache".into()),
3775 default_branch: Some("main".into()),
3776 repo_gate: RepoGate::InRepo,
3777 pushed_sha: None,
3778 worktree: LayerState::Clean,
3779 branch_ahead: LayerState::Clean,
3780 pr: Some(synthetic_pr.clone()),
3781 closes: synthetic_closes.clone(),
3782 pr_fetched_at: Some(std::time::SystemTime::now()),
3783 };
3784
3785 let snap = scan_in_test(dir.path(), &HashMap::new(), Some(&prev)).await;
3786 assert_eq!(snap.pr, Some(synthetic_pr), "cached PR must carry through scan");
3787 assert_eq!(snap.closes, synthetic_closes);
3788 assert_eq!(snap.pushed_sha, None, "nothing pushed in this repo");
3789 }
3790
3791 #[tokio::test(flavor = "current_thread")]
3796 async fn scan_skips_pr_fetch_on_default_branch() {
3797 let dir = tempfile::tempdir().expect("tempdir");
3798 init_repo(&dir, "main");
3799 write_file(&dir, "README.md", "hello\n");
3800 commit_all(&dir, "init");
3801
3802 let prev = GitDiffSnapshot {
3803 branch: GitBranch::Named("main".into()),
3804 default_branch: Some("main".into()),
3805 repo_gate: RepoGate::InRepo,
3806 pushed_sha: None,
3807 worktree: LayerState::Clean,
3808 branch_ahead: LayerState::Clean,
3809 pr: Some(GitPrInfo { number: 1, url: "url".into(), draft: false }),
3810 closes: Vec::new(),
3811 pr_fetched_at: Some(std::time::SystemTime::now()),
3812 };
3813 let snap = scan_in_test(dir.path(), &HashMap::new(), Some(&prev)).await;
3816 assert_eq!(snap.pr, None);
3817 assert!(snap.closes.is_empty());
3818 assert_eq!(snap.pushed_sha, None, "the walk must not run on the default branch");
3819 assert_eq!(snap.pr_fetched_at, None);
3820 }
3821
3822 #[test]
3823 fn classify_rev_parse_maps_output_to_repo_gate() {
3824 assert_eq!(
3827 classify_rev_parse(GitOutput::Ok(" main\n".to_owned()), RepoPresence::Present),
3828 Ok("main".to_owned())
3829 );
3830 assert_eq!(
3831 classify_rev_parse(GitOutput::Empty, RepoPresence::Absent),
3832 Err(RepoGate::NotARepo)
3833 );
3834 assert_eq!(
3835 classify_rev_parse(GitOutput::Oversize, RepoPresence::Present),
3836 Err(RepoGate::ScannerFailed)
3837 );
3838 }
3839
3840 #[test]
3845 fn a_non_zero_exit_is_resolved_by_the_repo_presence_probe() {
3846 assert_eq!(
3847 classify_rev_parse(GitOutput::Failed, RepoPresence::Absent),
3848 Err(RepoGate::NotARepo)
3849 );
3850 assert_eq!(
3851 classify_rev_parse(GitOutput::Failed, RepoPresence::Present),
3852 Err(RepoGate::ScannerFailed)
3853 );
3854 assert_eq!(
3855 classify_rev_parse(GitOutput::Failed, RepoPresence::Unusable),
3856 Err(RepoGate::ScannerFailed)
3857 );
3858 }
3859
3860 #[tokio::test(flavor = "current_thread")]
3864 async fn repo_presence_separates_a_bare_directory_from_an_unborn_repo() {
3865 let plain = tempfile::tempdir().expect("tempdir");
3866 assert_eq!(repo_presence(plain.path()).await, RepoPresence::Absent);
3867 assert_eq!(repo_presence(&plain.path().join("gone")).await, RepoPresence::Absent);
3868
3869 let repo = tempfile::tempdir().expect("tempdir");
3870 init_repo(&repo, "main");
3871 assert_eq!(repo_presence(repo.path()).await, RepoPresence::Present);
3872 }
3873
3874 #[tokio::test(flavor = "current_thread")]
3882 async fn a_checkout_git_refuses_is_unusable_not_absent() {
3883 let dir = tempfile::tempdir().expect("tempdir");
3884 init_repo(&dir, "main");
3885 std::fs::remove_file(dir.path().join(".git").join("HEAD")).expect("remove HEAD");
3886
3887 assert_eq!(repo_presence(dir.path()).await, RepoPresence::Unusable);
3888 assert_eq!(
3889 current_branch(dir.path()).await,
3890 Err(RepoGate::ScannerFailed),
3891 "a checkout git refuses must surface the scanner banner, not read as a non-repo",
3892 );
3893 assert_eq!(
3894 scan_in_test(dir.path(), &HashMap::new(), None).await.repo_gate,
3895 RepoGate::ScannerFailed,
3896 );
3897 }
3898
3899 #[cfg(unix)]
3904 #[tokio::test(flavor = "current_thread")]
3905 async fn a_dangling_dot_git_symlink_is_unusable_not_absent() {
3906 let dir = tempfile::tempdir().expect("tempdir");
3907 std::os::unix::fs::symlink(dir.path().join("gone-target"), dir.path().join(".git"))
3908 .expect("symlink");
3909
3910 assert_eq!(repo_presence(dir.path()).await, RepoPresence::Unusable);
3911 assert_eq!(
3912 scan_in_test(dir.path(), &HashMap::new(), None).await.repo_gate,
3913 RepoGate::ScannerFailed,
3914 );
3915 }
3916
3917 fn stale_base_fixture() -> (TempDir, TempDir) {
3923 let upstream = tempfile::tempdir().expect("bare remote");
3924 git_at(upstream.path(), &["init", "-q", "--bare"]);
3925 let dir = tempfile::tempdir().expect("tempdir");
3926 init_repo(&dir, "main");
3927 write_file(&dir, "base.txt", "base\n");
3928 commit_all(&dir, "X base");
3929 let base = rev_parse_head(&dir);
3930 git_at(dir.path(), &["remote", "add", "origin", &upstream.path().to_string_lossy()]);
3931 git_at(dir.path(), &["push", "-q", "-u", "origin", "main"]);
3932 write_file(&dir, "upstream.txt", "upstream\n");
3935 commit_all(&dir, "Y upstream");
3936 git_at(dir.path(), &["push", "-q", "origin", "main"]);
3937 git_at(dir.path(), &["checkout", "-q", "-b", "feature"]);
3940 write_file(&dir, "feat.txt", "feat\n");
3941 commit_all(&dir, "Z feat");
3942 git_at(dir.path(), &["update-ref", "refs/remotes/origin/main", &base]);
3945 git_at(
3946 dir.path(),
3947 &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"],
3948 );
3949 (upstream, dir)
3950 }
3951
3952 #[tokio::test(flavor = "current_thread")]
3961 async fn the_display_refresh_moves_a_stale_base_before_the_diff_is_read() {
3962 let (upstream, dir) = stale_base_fixture();
3963
3964 let stale = StdCommand::new("git")
3965 .arg("-C")
3966 .arg(dir.path())
3967 .args(["diff", "--name-only", "origin/main...HEAD"])
3968 .output()
3969 .expect("git ok");
3970 assert!(stale.status.success(), "the control range reads");
3971 let stale_files = String::from_utf8_lossy(&stale.stdout);
3972 assert!(
3973 stale_files.contains("upstream.txt") && stale_files.contains("feat.txt"),
3974 "control: the stale base folds upstream's commit into the branch diff: {stale_files:?}",
3975 );
3976
3977 let base = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
3978 assert_eq!(base.state, BaseState::Current, "the fetch answered a current base");
3979 assert_eq!(
3980 rev_parse(dir.path(), "refs/remotes/origin/main"),
3981 rev_parse(upstream.path(), "main"),
3982 "and it moved the remote-tracking ref the layer reads",
3983 );
3984 assert_eq!(
3985 base.base_ref.as_deref(),
3986 Some(rev_parse(upstream.path(), "main").as_str()),
3987 "and the sha it pins is the ref's own after the fetch",
3988 );
3989
3990 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3991 let _ = settle_background_fetches().await;
3994 let LayerState::Populated(ahead) = &snap.branch_ahead else {
3995 panic!("the feature branch has one commit ahead of the refreshed base");
3996 };
3997 assert_eq!(ahead.commit_count, 1, "the branch's own commit alone");
3998 assert_eq!(
3999 ahead.stats.files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(),
4000 vec!["feat.txt"],
4001 "upstream's already-merged commit is not the branch's change",
4002 );
4003 }
4004
4005 #[tokio::test(flavor = "current_thread")]
4011 async fn a_base_the_fetch_could_not_refresh_is_unchecked_and_pinned_to_the_stale_ref() {
4012 let (_upstream, dir) = stale_base_fixture();
4013 let stale = rev_parse(dir.path(), "refs/remotes/origin/main");
4014 let bin = tempfile::tempdir().expect("wrapper dir");
4015 fail_fetch_at(dir.path(), bin.path());
4016
4017 let base = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
4018
4019 assert_eq!(
4020 base.state,
4021 BaseState::Unverified,
4022 "the fetch did not run, so the base is not claimed",
4023 );
4024 assert!(base.error.is_some(), "and git's own words ride the answer: {base:?}");
4025 assert_eq!(
4026 base.base_ref.as_deref(),
4027 Some(stale.as_str()),
4028 "pinned to the ref the checkout still holds",
4029 );
4030 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
4034 crate::env::git_command::clear_git_point(dir.path());
4035 let _ = settle_background_fetches().await;
4036 let LayerState::Populated(ahead) = &snap.branch_ahead else {
4037 panic!("the branch-ahead layer is read from the ref it holds");
4038 };
4039 assert_eq!(ahead.commit_count, 2, "the unfetched range still folds upstream's commit in");
4040 assert!(
4041 ahead.stats.files.iter().any(|file| file.path == "upstream.txt"),
4042 "so the file list is the stale one, which is what the label must cover",
4043 );
4044 }
4045
4046 #[tokio::test(flavor = "current_thread")]
4049 async fn a_base_with_no_reachable_remote_is_unverified() {
4050 let dir = tempfile::tempdir().expect("tempdir");
4051 init_repo(&dir, "main");
4052 write_file(&dir, "base.txt", "base\n");
4053 commit_all(&dir, "base");
4054 git_at(dir.path(), &["remote", "add", "origin", "/nonexistent/forge-base.git"]);
4055 git_at(dir.path(), &["update-ref", "refs/remotes/origin/main", "HEAD"]);
4056 git_at(
4057 dir.path(),
4058 &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"],
4059 );
4060
4061 assert_eq!(
4062 refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await.state,
4063 BaseState::Unverified,
4064 "an unreachable remote leaves the base unchecked, and the refresh says so",
4065 );
4066 }
4067
4068 #[tokio::test(flavor = "current_thread")]
4072 async fn a_local_base_with_no_origin_remote_is_local() {
4073 let dir = tempfile::tempdir().expect("tempdir");
4074 init_repo(&dir, "main");
4075 write_file(&dir, "base.txt", "base\n");
4076 commit_all(&dir, "base");
4077
4078 assert_eq!(
4079 refresh_base_within(dir.path(), "main", TEST_GIT_BUDGET).await.state,
4080 BaseState::Local,
4081 "a repository with no origin has nothing this base can lag behind",
4082 );
4083 }
4084
4085 #[tokio::test(flavor = "current_thread")]
4090 async fn a_local_base_beside_an_unanswerable_origin_probe_is_unverified() {
4091 let dir = tempfile::tempdir().expect("tempdir");
4092 init_repo(&dir, "main");
4093 write_file(&dir, "base.txt", "base\n");
4094 commit_all(&dir, "base");
4095 let bin = tempfile::tempdir().expect("wrapper dir");
4096 unanswerable_origin_at(dir.path(), bin.path());
4097 let capture = LogCapture::default();
4098 let subscriber = tracing_subscriber::fmt()
4101 .with_writer(capture.clone())
4102 .with_ansi(false)
4105 .with_max_level(tracing::Level::DEBUG)
4106 .finish();
4107
4108 let guard = tracing::subscriber::set_default(subscriber);
4109 let refresh = refresh_base_within(dir.path(), "main", TEST_GIT_BUDGET).await;
4110 drop(guard);
4111 crate::env::git_command::clear_git_point(dir.path());
4112
4113 assert_eq!(
4114 refresh.state,
4115 BaseState::Unverified,
4116 "an unanswered origin probe leaves the base unchecked, not local",
4117 );
4118 let log = String::from_utf8_lossy(&capture.0.lock().clone()).into_owned();
4119 assert!(
4120 log.contains("git_base_origin_probe_unanswered"),
4121 "the decision is its own event: {log}",
4122 );
4123 assert!(
4124 log.contains(&dir.path().display().to_string()),
4125 "and it names the checkout a reader has to look at: {log}",
4126 );
4127 assert!(log.contains("git_refused"), "and why the probe could not answer: {log}");
4128 }
4129
4130 #[tokio::test(flavor = "current_thread")]
4134 async fn a_local_base_that_tracks_an_upstream_is_unverified() {
4135 let upstream = tempfile::tempdir().expect("bare remote");
4136 git_at(upstream.path(), &["init", "-q", "--bare"]);
4137 let dir = tempfile::tempdir().expect("tempdir");
4138 init_repo(&dir, "main");
4139 write_file(&dir, "base.txt", "base\n");
4140 commit_all(&dir, "base");
4141 git_at(dir.path(), &["remote", "add", "upstream", &upstream.path().to_string_lossy()]);
4142 git_at(dir.path(), &["push", "-q", "-u", "upstream", "main"]);
4143
4144 assert_eq!(
4145 refresh_base_within(dir.path(), "main", TEST_GIT_BUDGET).await.state,
4146 BaseState::Unverified,
4147 "a base tracking a remote nobody fetched is not a local base",
4148 );
4149 }
4150
4151 #[tokio::test(flavor = "current_thread")]
4155 async fn a_pinned_scan_reads_the_sha_it_was_given() {
4156 let (upstream, dir) = stale_base_fixture();
4157 let stale = rev_parse(dir.path(), "refs/remotes/origin/main");
4158 git_at(
4160 dir.path(),
4161 &["update-ref", "refs/remotes/origin/main", &rev_parse(upstream.path(), "main")],
4162 );
4163
4164 let by_name = scan_serving_cached_pr(dir.path(), &HashMap::new(), None).await;
4165 let LayerState::Populated(named) = &by_name.branch_ahead else {
4166 panic!("the branch layer is read");
4167 };
4168 assert_eq!(named.commit_count, 1, "the by-name scan reads the moved ref");
4169
4170 let pinned = scan_pinned(dir.path(), &HashMap::new(), None, &stale).await;
4171 let LayerState::Populated(ahead) = &pinned.branch_ahead else {
4172 panic!("the pinned scan reads the stale base");
4173 };
4174 assert_eq!(ahead.commit_count, 2, "the pinned scan reads the sha it was given");
4175 let paths: Vec<&str> = ahead.stats.files.iter().map(|file| file.path.as_str()).collect();
4176 assert!(
4177 paths.contains(&"upstream.txt") && paths.contains(&"feat.txt"),
4178 "and that base's own files, not the moved ref's: {paths:?}",
4179 );
4180 }
4181
4182 #[tokio::test(flavor = "current_thread")]
4186 async fn the_content_read_follows_the_pinned_base() {
4187 let (upstream, dir) = stale_base_fixture();
4188 let stale = rev_parse(dir.path(), "refs/remotes/origin/main");
4189 git_at(
4190 dir.path(),
4191 &["update-ref", "refs/remotes/origin/main", &rev_parse(upstream.path(), "main")],
4192 );
4193 let snapshot = scan_serving_cached_pr(dir.path(), &HashMap::new(), None).await;
4194
4195 let content = super::content::scan_within(
4196 dir.path(),
4197 &snapshot,
4198 Some(BaseState::Current),
4199 Some(&stale),
4200 None,
4201 TEST_GIT_BUDGET,
4202 )
4203 .await;
4204
4205 let LayerState::Populated(files) = &content.branch_ahead else {
4206 panic!("the branch layer is read");
4207 };
4208 let paths: Vec<&str> = files.iter().map(|file| file.path.as_str()).collect();
4209 assert!(
4210 paths.contains(&"upstream.txt") && paths.contains(&"feat.txt"),
4211 "the content is the pinned base's range, not the moved ref's: {paths:?}",
4212 );
4213 }
4214
4215 #[tokio::test(flavor = "current_thread")]
4219 async fn a_refresh_stamps_the_kicks_window() {
4220 let (_upstream, dir) = stale_base_fixture();
4221
4222 let refresh = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
4223 assert_eq!(refresh.state, BaseState::Current, "the refresh fetched");
4224 assert!(
4225 !claim_refresh_slot(dir.path(), Instant::now(), WATCH_REFRESH_INTERVAL),
4226 "and a kick inside the window it stamped does not fetch again",
4227 );
4228 }
4229
4230 #[test]
4231 fn the_refresh_window_gates_the_next_fetch() {
4232 let now = Instant::now();
4233 let window = Duration::from_secs(240);
4234 assert!(refresh_due(None, now, window), "no prior fetch is due");
4235 assert!(!refresh_due(Some(now), now, window), "a just-now fetch is throttled");
4236 let stale = now
4237 .checked_sub(window + Duration::from_secs(1))
4238 .expect("the monotonic clock is past a window");
4239 assert!(refresh_due(Some(stale), now, window), "a stale prior fetch is due again");
4240 }
4241
4242 #[test]
4243 fn a_claimed_slot_throttles_and_prunes() {
4244 let key = tempfile::tempdir().expect("a key of its own");
4245 let expired = tempfile::tempdir().expect("another key");
4246 let now = Instant::now();
4247 let window = Duration::from_secs(240);
4248
4249 assert!(claim_refresh_slot(key.path(), now, window), "the first claim is due");
4250 assert!(!claim_refresh_slot(key.path(), now, window), "an immediate re-claim is throttled");
4251 assert!(claim_refresh_slot(expired.path(), now, window), "another key has its own window");
4252 assert!(
4253 claim_refresh_slot(key.path(), now + window, window),
4254 "a claim once the window elapses is due again",
4255 );
4256 let map = LAST_WATCH_REFRESH.get().expect("the map exists").lock();
4257 assert!(
4258 !map.contains_key(expired.path()),
4259 "and the claim pruned the other key's expired slot",
4260 );
4261 }
4262
4263 #[tokio::test(flavor = "current_thread")]
4266 async fn a_local_base_beside_an_origin_remote_is_unverified() {
4267 let upstream = tempfile::tempdir().expect("bare remote");
4268 git_at(upstream.path(), &["init", "-q", "--bare"]);
4269 let dir = tempfile::tempdir().expect("tempdir");
4270 init_repo(&dir, "main");
4271 write_file(&dir, "base.txt", "base\n");
4272 commit_all(&dir, "base");
4273 git_at(dir.path(), &["remote", "add", "origin", &upstream.path().to_string_lossy()]);
4274
4275 assert_eq!(
4276 refresh_base_within(dir.path(), "main", TEST_GIT_BUDGET).await.state,
4277 BaseState::Unverified,
4278 "an origin exists, so a local base is a base nobody fetched",
4279 );
4280 }
4281
4282 #[tokio::test(flavor = "current_thread")]
4286 async fn linked_worktrees_join_one_repository_run() {
4287 let dir = tempfile::tempdir().expect("tempdir");
4288 init_repo(&dir, "main");
4289 write_file(&dir, "base.txt", "base\n");
4290 commit_all(&dir, "base");
4291 let beside = tempfile::tempdir().expect("a second directory");
4292 let linked = beside.path().join("wt");
4293 git_at(dir.path(), &["worktree", "add", "-q", "-b", "linked", &linked.to_string_lossy()]);
4294
4295 let main_key = repo_key(dir.path(), TEST_GIT_BUDGET).await;
4296 let linked_key = repo_key(&linked, TEST_GIT_BUDGET).await;
4297 assert_eq!(main_key, linked_key, "a worktree and its checkout share one ref store");
4298 let (first, leader) = join_base_run((main_key, "origin/main".to_owned()));
4299 assert!(leader, "the first caller starts the run");
4300 let (joined, leader) = join_base_run((linked_key, "origin/main".to_owned()));
4301 assert!(!leader, "and the worktree's caller joins it rather than starting a second");
4302 assert!(std::sync::Arc::ptr_eq(&first, &joined), "the one run answers both");
4303 }
4304
4305 #[tokio::test(flavor = "current_thread")]
4310 async fn a_base_the_users_git_has_locked_is_unchecked() {
4311 let (_upstream, dir) = stale_base_fixture();
4312 let bin = tempfile::tempdir().expect("wrapper dir");
4313 lock_fetch_at(dir.path(), bin.path());
4314
4315 assert_eq!(
4316 refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await.state,
4317 BaseState::Unverified,
4318 "a lock the fetch could not take leaves the base unchecked",
4319 );
4320 crate::env::git_command::clear_git_point(dir.path());
4321 }
4322
4323 #[tokio::test(flavor = "current_thread")]
4326 async fn a_lock_refusal_is_named_with_its_checkout_and_gits_words() {
4327 let (_upstream, dir) = stale_base_fixture();
4328 let bin = tempfile::tempdir().expect("wrapper dir");
4329 lock_fetch_at(dir.path(), bin.path());
4330 let capture = LogCapture::default();
4331 let subscriber = tracing_subscriber::fmt()
4332 .with_writer(capture.clone())
4333 .with_ansi(false)
4334 .with_max_level(tracing::Level::DEBUG)
4337 .finish();
4338 let guard = tracing::subscriber::set_default(subscriber);
4339
4340 let _ = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
4341 drop(guard);
4342 crate::env::git_command::clear_git_point(dir.path());
4343
4344 let log = String::from_utf8_lossy(&capture.0.lock().clone()).into_owned();
4345 assert!(log.contains("git_base_fetch_lock_refused"), "the lock event is named: {log}");
4346 assert!(
4347 log.contains(&dir.path().display().to_string()),
4348 "and the repo it happened in: {log}",
4349 );
4350 assert!(
4351 log.contains("cannot lock ref"),
4352 "and git's own words, which carry the lock file: {log}",
4353 );
4354 }
4355
4356 #[tokio::test(flavor = "current_thread")]
4361 async fn a_base_rewritten_past_is_current_after_its_fetch() {
4362 let (upstream, dir) = stale_base_fixture();
4363 let newer = rev_parse(upstream.path(), "main");
4364 let older = rev_parse(upstream.path(), "main~1");
4365 git_at(dir.path(), &["update-ref", "refs/remotes/origin/main", &newer]);
4367 git_at(upstream.path(), &["update-ref", "refs/heads/main", &older]);
4368
4369 let refresh = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
4370 crate::env::git_command::clear_git_point(dir.path());
4371
4372 assert_eq!(
4373 refresh.state,
4374 BaseState::Current,
4375 "the fetch wrote the remote's tip over the rewritten base",
4376 );
4377 assert_eq!(
4378 refresh.base_ref.as_deref(),
4379 Some(older.as_str()),
4380 "and the sha it pins is the ref's own after the fetch",
4381 );
4382 }
4383
4384 #[test]
4387 fn a_ref_lock_is_told_apart_from_a_dead_network() {
4388 assert!(is_lock_failure(
4389 "fatal: cannot lock ref 'refs/remotes/origin/main': is at 1a2b3c but expected 4d5e6f",
4390 ));
4391 assert!(is_lock_failure(
4392 "error: Unable to create '/repo/.git/refs/remotes/origin/main.lock': File exists.",
4393 ));
4394 assert!(!is_lock_failure(
4395 "fatal: unable to access 'https://github.com/x/y.git/': Could not resolve host: github.com",
4396 ));
4397 }
4398
4399 #[tokio::test(flavor = "current_thread")]
4404 async fn a_local_base_answers_local_without_a_fetch() {
4405 let dir = tempfile::tempdir().expect("tempdir");
4406 init_repo(&dir, "main");
4407 write_file(&dir, "base.txt", "base\n");
4408 commit_all(&dir, "X base");
4409 git_at(dir.path(), &["checkout", "-q", "-b", "feature"]);
4410 write_file(&dir, "feat.txt", "feat\n");
4411 commit_all(&dir, "Z feat");
4412
4413 let answer = refresh_base_for_display(dir.path(), "main").await;
4414
4415 assert_eq!(
4416 answer.state,
4417 BaseState::Local,
4418 "a base with nothing to lag behind is local: {answer:?}",
4419 );
4420 assert!(answer.base_ref.is_some(), "resolved to the local tip");
4421 assert!(answer.error.is_none() && !answer.cut && !answer.unanswered);
4422 assert_eq!(settle_background_fetches().await, 0, "a local base fetches nothing");
4423 }
4424
4425 #[tokio::test(flavor = "current_thread")]
4431 async fn a_reading_with_no_measurement_answers_at_once_and_kicks_the_refresh() {
4432 let (_upstream, dir) = stale_base_fixture();
4433 let bin = tempfile::tempdir().expect("wrapper dir");
4434 let held = held_fetch_at(dir.path(), bin.path());
4435
4436 let reading = tokio::spawn({
4437 let dir = dir.path().to_path_buf();
4438 async move { refresh_base_for_display(&dir, "origin/main").await }
4439 });
4440 let answer = timeout(TEST_GIT_BUDGET, reading)
4441 .await
4442 .expect("the reading did not wait on the fetch")
4443 .expect("the read task finished");
4444
4445 wait_for_within(|| held.starts() == 1, "the reading's refresh to start", TEST_GIT_BUDGET)
4446 .await;
4447 assert!(held.open(), "the read's refresh runs off the read: the fetch is still in flight");
4448 assert_eq!(
4449 answer.state,
4450 BaseState::Unverified,
4451 "with no check in hand, the base is unchecked"
4452 );
4453 assert!(answer.base_ref.is_some(), "and pinned to a local tip it did read");
4454 assert!(answer.error.is_none() && !answer.cut && !answer.unanswered);
4455
4456 held.release();
4457 let _ = settle_background_fetches().await;
4458 crate::env::git_command::clear_git_point(dir.path());
4459 }
4460
4461 #[tokio::test(flavor = "current_thread")]
4468 async fn a_reading_answers_a_stored_check_past_thirty_seconds_and_the_refresh_lands() {
4469 let (upstream, dir) = stale_base_fixture();
4470 let bin = tempfile::tempdir().expect("wrapper dir");
4471 let held = held_fetch_at(dir.path(), bin.path());
4472
4473 let stale_tip = rev_parse(dir.path(), "refs/remotes/origin/main");
4474 let measured_at = Instant::now()
4475 .checked_sub(Duration::from_secs(45))
4476 .expect("the test clock is past boot");
4477 remember_measurement(
4478 dir.path(),
4479 "origin/main",
4480 &BaseRefresh {
4481 state: BaseState::Current,
4482 base_ref: Some(stale_tip.clone()),
4483 error: None,
4484 cut: false,
4485 unanswered: false,
4486 at: measured_at,
4487 },
4488 );
4489
4490 let reading = tokio::spawn({
4491 let dir = dir.path().to_path_buf();
4492 async move { refresh_base_for_display(&dir, "origin/main").await }
4493 });
4494 let answer = timeout(TEST_GIT_BUDGET, reading)
4495 .await
4496 .expect("the reading did not wait on the fetch")
4497 .expect("the read task finished");
4498
4499 assert_eq!(answer.state, BaseState::Current, "the last completed check answers the read");
4500 assert_eq!(
4501 answer.base_ref.as_deref(),
4502 Some(stale_tip.as_str()),
4503 "pinned to the base in hand"
4504 );
4505 assert_eq!(answer.at, measured_at, "at that check's own instant");
4506 wait_for_within(
4507 || held.starts() == 1,
4508 "the reading's own refresh to start",
4509 TEST_GIT_BUDGET,
4510 )
4511 .await;
4512 assert!(held.open(), "the refresh runs off the read: the fetch is still in flight");
4513
4514 held.release();
4515 let _ = settle_background_fetches().await;
4516 crate::env::git_command::clear_git_point(dir.path());
4517
4518 let next = refresh_base_for_display(dir.path(), "origin/main").await;
4519 assert_eq!(next.state, BaseState::Current, "the refreshed base answers the next read");
4520 assert_eq!(
4521 next.base_ref.as_deref(),
4522 Some(rev_parse(upstream.path(), "main").as_str()),
4523 "pinned to the tip the fetch moved the ref to",
4524 );
4525 assert_eq!(held.starts(), 1, "and the next read paid no second fetch");
4526 }
4527
4528 #[tokio::test(flavor = "current_thread")]
4535 async fn scan_feature_branch_compares_against_origin_main_not_stale_local_main() {
4536 let dir = tempfile::tempdir().expect("tempdir");
4537 init_repo(&dir, "main");
4538 let run = |args: &[&str]| {
4539 let out =
4540 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git");
4541 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
4542 };
4543 write_file(&dir, "base.txt", "base\n");
4544 commit_all(&dir, "X base");
4545 run(&["checkout", "-q", "-b", "upstream"]);
4547 write_file(&dir, "merged.txt", "merged\n");
4548 commit_all(&dir, "Y merged");
4549 run(&["update-ref", "refs/remotes/origin/main", "upstream"]);
4550 run(&["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
4551 run(&["checkout", "-q", "main"]);
4553 run(&["branch", "-D", "upstream"]);
4554 std::fs::create_dir_all(dir.path().join(".claude/worktrees")).expect("mkdir");
4556 let wt = dir.path().join(".claude/worktrees/w");
4557 run(&["worktree", "add", "-q", wt.to_str().expect("utf8"), "-b", "feat/w", "origin/main"]);
4558 let wt_run = |args: &[&str]| {
4559 let out = StdCommand::new("git").arg("-C").arg(&wt).args(args).output().expect("git");
4560 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
4561 };
4562 std::fs::write(wt.join("z.txt"), "z\n").expect("write z");
4563 wt_run(&["add", "-A"]);
4564 wt_run(&["commit", "-q", "-m", "Z own"]);
4565
4566 let snap = scan_in_test(&wt, &HashMap::new(), None).await;
4567 assert_eq!(
4568 snap.default_branch.as_deref(),
4569 Some("origin/main"),
4570 "compares against the remote-tracking ref, not stripped local main",
4571 );
4572 let LayerState::Populated(ahead) = &snap.branch_ahead else {
4573 panic!("branch-ahead populated for the worker branch");
4574 };
4575 assert_eq!(
4576 ahead.commit_count, 1,
4577 "only the branch's own commit vs origin/main, not the already-merged one",
4578 );
4579 assert_eq!(ahead.stats.total_files, 1);
4580 assert_eq!(
4581 ahead.stats.files[0].path, "z.txt",
4582 "merged.txt (already on origin/main) is excluded from the branch-ahead diff",
4583 );
4584 }
4585
4586 #[tokio::test(flavor = "current_thread")]
4592 async fn scan_on_local_main_ahead_of_origin_skips_branch_ahead() {
4593 let dir = tempfile::tempdir().expect("tempdir");
4594 init_repo(&dir, "main");
4595 let run = |args: &[&str]| {
4596 let out =
4597 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git");
4598 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
4599 };
4600 write_file(&dir, "base.txt", "base\n");
4601 commit_all(&dir, "base");
4602 run(&["update-ref", "refs/remotes/origin/main", "HEAD"]);
4603 run(&["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
4604 write_file(&dir, "local.txt", "local\n");
4606 commit_all(&dir, "unpushed");
4607
4608 let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
4609 assert_eq!(snap.default_branch.as_deref(), Some("origin/main"));
4610 assert!(
4611 matches!(snap.branch_ahead, LayerState::Clean),
4612 "on `main` the branch-ahead layer stays empty even when ahead of origin/main",
4613 );
4614 }
4615
4616 #[tokio::test(flavor = "current_thread")]
4622 async fn a_stored_read_leaves_the_base_alone() {
4623 let (upstream, dir) = stale_base_fixture();
4624 let before = rev_parse(dir.path(), "refs/remotes/origin/main");
4625
4626 let snap = super::scan_serving_cached_pr(dir.path(), &HashMap::new(), None).await;
4627
4628 assert_eq!(
4629 rev_parse(dir.path(), "refs/remotes/origin/main"),
4630 before,
4631 "a record read leaves the ref alone",
4632 );
4633 assert_ne!(
4634 rev_parse(dir.path(), "refs/remotes/origin/main"),
4635 rev_parse(upstream.path(), "main"),
4636 "so the stale ref is still stale after a record read",
4637 );
4638 let LayerState::Populated(ahead) = &snap.branch_ahead else {
4639 panic!("the layer is read from the ref the scan holds");
4640 };
4641 assert_eq!(ahead.commit_count, 2, "and the stale range is what the read answers");
4642 }
4643
4644 #[tokio::test(flavor = "current_thread")]
4649 async fn an_ordinary_scan_kicks_a_throttled_fetch_for_the_next_read() {
4650 let (upstream, dir) = stale_base_fixture();
4651
4652 let _ = scan_in_test(dir.path(), &HashMap::new(), None).await;
4653 let kicked =
4654 LAST_WATCH_REFRESH.get().is_some_and(|map| map.lock().contains_key(dir.path()));
4655 assert!(kicked, "an origin-tracking scan claims (kicks) a background fetch");
4656
4657 assert!(
4658 settle_background_fetches().await >= 1,
4659 "and there is one to settle, so the kick was real",
4660 );
4661 assert_eq!(
4662 rev_parse(dir.path(), "refs/remotes/origin/main"),
4663 rev_parse(upstream.path(), "main"),
4664 "which leaves the ref fresh for the next read",
4665 );
4666 }
4667
4668 #[tokio::test(flavor = "current_thread")]
4673 async fn a_scan_without_base_fetch_kicks_nothing() {
4674 let (upstream, dir) = stale_base_fixture();
4675 let before = rev_parse(dir.path(), "refs/remotes/origin/main");
4676
4677 let _ = scan_without_base_fetch(dir.path(), &HashMap::new(), None).await;
4678
4679 assert_eq!(
4680 settle_background_fetches().await,
4681 0,
4682 "the quiet scan kicks no background fetch",
4683 );
4684 assert_eq!(
4685 rev_parse(dir.path(), "refs/remotes/origin/main"),
4686 before,
4687 "and the ref is where the caller's own fetch will find it",
4688 );
4689 assert_ne!(before, rev_parse(upstream.path(), "main"), "precondition: the base is stale");
4690 }
4691
4692 #[tokio::test(flavor = "current_thread")]
4696 async fn a_scan_kicks_its_refresh_and_the_measurement_lands_for_the_next_one() {
4697 let (upstream, dir) = stale_base_fixture();
4698 let bin = tempfile::tempdir().expect("wrapper dir");
4699 let held = held_fetch_at(dir.path(), bin.path());
4700
4701 let scanned =
4706 timeout(Duration::from_secs(90), scan_in_test(dir.path(), &HashMap::new(), None))
4707 .await
4708 .expect("the scan answered while its fetch was still held");
4709 let _ = scanned;
4710 wait_for(|| held.starts() == 1, "the kicked fetch to start").await;
4711 assert!(held.open(), "the fetch the scan kicked is provably still in flight");
4712
4713 held.release();
4714 let _ = settle_background_fetches().await;
4715 crate::env::git_command::clear_git_point(dir.path());
4716 assert_eq!(
4717 rev_parse(dir.path(), "refs/remotes/origin/main"),
4718 rev_parse(upstream.path(), "main"),
4719 "and the fetch it kicked moved the base to the remote's tip",
4720 );
4721 assert_eq!(
4722 last_base_measurement(dir.path(), "origin/main").map(|measured| measured.state),
4723 Some(BaseState::Current),
4724 "and the state it measured is what the row carries next",
4725 );
4726 }
4727
4728 #[tokio::test(flavor = "current_thread")]
4737 async fn a_failed_fetch_carries_gits_own_words() {
4738 let (_upstream, dir) = stale_base_fixture();
4739 let bin = tempfile::tempdir().expect("wrapper dir");
4740 let release = bin.path().join("fetch-release");
4741 let body = format!(
4742 "while [ ! -e '{release}' ]; do /bin/sleep 0.02; done; \
4743 echo 'fatal: forced fetch failure' >&2; exit 128",
4744 release = release.display(),
4745 );
4746 write_wrapper(dir.path(), bin.path(), "held-failing-git", &[], &body);
4747
4748 let unchecked = refresh_base_for_display(dir.path(), "origin/main").await;
4749 assert_eq!(unchecked.state, BaseState::Unverified);
4750 assert!(
4751 unchecked.error.is_none(),
4752 "the read that kicked the fetch names no failure it has not seen: {unchecked:?}",
4753 );
4754 std::fs::write(&release, b"go").expect("release the fetch");
4755 let _ = settle_background_fetches().await;
4756
4757 let refresh = refresh_base_for_display(dir.path(), "origin/main").await;
4758 crate::env::git_command::clear_git_point(dir.path());
4759 assert_eq!(
4760 refresh.error.as_deref(),
4761 Some("fatal: forced fetch failure"),
4762 "the record carries git's first line",
4763 );
4764 assert_eq!(
4765 refresh.state,
4766 BaseState::Unverified,
4767 "the fetch did not run and no tip is read, so the base is not claimed",
4768 );
4769 assert!(!refresh.cut, "a fetch that failed for its own reason was not cut by a bound");
4770 }
4771
4772 #[tokio::test(flavor = "current_thread")]
4776 async fn a_published_answer_waits_for_a_caller_that_arrives_after_it() {
4777 let key = (PathBuf::from("/an-answer-kept"), "origin/main".to_owned());
4778 let (run, leader) = join_base_run(key.clone());
4779 assert!(leader, "the test leads");
4780 run.publish(BaseRefresh {
4781 state: BaseState::Current,
4782 base_ref: Some("abc123".to_owned()),
4783 error: None,
4784 cut: false,
4785 unanswered: false,
4786 at: Instant::now(),
4787 });
4788 let (joined, leader) = join_base_run(key);
4791 assert!(!leader, "the late caller joins the run");
4792 let answer = timeout(Duration::from_secs(2), joined.wait())
4793 .await
4794 .expect("the published answer is not thrown away");
4795 assert_eq!(answer.base_ref.as_deref(), Some("abc123"));
4796 }
4797
4798 #[tokio::test(flavor = "current_thread")]
4802 async fn a_cancelled_leader_answers_its_waiters() {
4803 let (upstream, dir) = stale_base_fixture();
4804 let bin = tempfile::tempdir().expect("wrapper dir");
4805 let held = held_fetch_at(dir.path(), bin.path());
4806
4807 let leader = tokio::spawn({
4808 let dir = dir.path().to_path_buf();
4809 async move { refresh_base_within(&dir, "origin/main", TEST_GIT_BUDGET).await }
4810 });
4811 wait_for(|| held.starts() == 1, "the leader's fetch to start").await;
4812 let follower = tokio::spawn({
4813 let dir = dir.path().to_path_buf();
4814 async move { refresh_base_within(&dir, "origin/main", TEST_GIT_BUDGET).await }
4815 });
4816 wait_for_within(
4817 || held.probes() >= 2,
4818 "the follower to reach the registry",
4819 TEST_GIT_BUDGET,
4820 )
4821 .await;
4822 tokio::time::sleep(Duration::from_millis(200)).await;
4826 leader.abort();
4827
4828 let answer = timeout(Duration::from_secs(5), follower)
4829 .await
4830 .expect("the follower was answered")
4831 .expect("the follower's task finished");
4832 held.release();
4833 let _ = settle_background_fetches().await;
4834 crate::env::git_command::clear_git_point(dir.path());
4835 let _ = upstream;
4836 assert!(
4837 answer.error.as_deref().is_some_and(|words| words.contains("without an answer")),
4838 "a leader that ended unanswered is named: {answer:?}",
4839 );
4840 assert!(
4841 last_base_measurement(dir.path(), "origin/main").is_none(),
4842 "and a placeholder state is not stored as the checkout's measurement",
4843 );
4844 }
4845
4846 #[tokio::test(flavor = "current_thread")]
4857 async fn a_reading_answers_unchecked_rather_than_joining_a_kicked_fetch() {
4858 let (_upstream, dir) = stale_base_fixture();
4859 let bin = tempfile::tempdir().expect("wrapper dir");
4860 let held = held_fetch_at(dir.path(), bin.path());
4861
4862 kick_background_fetch(dir.path(), "origin/main", TEST_GIT_BUDGET);
4863 wait_for_within(|| held.starts() == 1, "the kicked fetch to start", TEST_GIT_BUDGET).await;
4864
4865 let reading = tokio::spawn({
4866 let dir = dir.path().to_path_buf();
4867 async move { refresh_base_for_display(&dir, "origin/main").await }
4868 });
4869 let answer = timeout(TEST_GIT_BUDGET, reading)
4870 .await
4871 .expect("the reading did not wait on the kicked fetch")
4872 .expect("the read task finished");
4873
4874 assert_eq!(held.starts(), 1, "the reading started no fetch of its own");
4875 assert!(held.open(), "the kicked fetch is provably still in flight");
4876 assert_eq!(
4877 answer.state,
4878 BaseState::Unverified,
4879 "the reading says the base was not checked"
4880 );
4881 assert!(answer.base_ref.is_some(), "and pins to a local tip it did read");
4882 assert!(answer.error.is_none() && !answer.cut && !answer.unanswered);
4883
4884 held.release();
4885 let _ = settle_background_fetches().await;
4886 crate::env::git_command::clear_git_point(dir.path());
4887 }
4888
4889 #[tokio::test(flavor = "current_thread")]
4894 async fn a_reading_after_a_finished_refresh_answers_without_fetching_again() {
4895 let (_upstream, dir) = stale_base_fixture();
4896 let bin = tempfile::tempdir().expect("wrapper dir");
4897 let held = held_fetch_at(dir.path(), bin.path());
4898
4899 kick_background_fetch(dir.path(), "origin/main", TEST_GIT_BUDGET);
4900 wait_for_within(|| held.starts() == 1, "the kicked fetch to start", TEST_GIT_BUDGET).await;
4901 held.release();
4902 let _ = settle_background_fetches().await;
4903 wait_for(
4904 || last_base_measurement(dir.path(), "origin/main").is_some(),
4905 "the measurement to land",
4906 )
4907 .await;
4908 let measured = last_base_measurement(dir.path(), "origin/main");
4909
4910 let answer = timeout(TEST_GIT_BUDGET, refresh_base_for_display(dir.path(), "origin/main"))
4911 .await
4912 .expect("the reading answered from the measurement it holds");
4913
4914 assert_eq!(held.starts(), 1, "the finished run answered the read; no second fetch");
4915 assert_eq!(
4916 Some(answer.state),
4917 measured.map(|measured| measured.state),
4918 "the state is the measurement the run took"
4919 );
4920 assert_eq!(answer.state, BaseState::Current, "the fixture's fetch makes the base current");
4921 assert!(answer.base_ref.is_some(), "pinned to the tip the fetch left");
4922
4923 assert!(answer.error.is_none() && !answer.cut && !answer.unanswered);
4924 crate::env::git_command::clear_git_point(dir.path());
4925 }
4926
4927 #[tokio::test(flavor = "current_thread")]
4932 async fn a_sessionless_read_waits_on_the_kick_that_claimed_its_slot() {
4933 let (upstream, dir) = stale_base_fixture();
4934 let bin = tempfile::tempdir().expect("wrapper dir");
4935 let held = held_fetch_at(dir.path(), bin.path());
4936
4937 kick_background_fetch(dir.path(), "origin/main", TEST_GIT_BUDGET);
4938 wait_for_within(|| held.starts() == 1, "the kick's fetch to start", TEST_GIT_BUDGET).await;
4939 let probes_before = held.probes();
4940
4941 let reader = tokio::spawn({
4942 let dir = dir.path().to_path_buf();
4943 async move { refresh_base_now(&dir, "origin/main").await }
4944 });
4945 wait_for_within(
4947 || held.probes() > probes_before,
4948 "the read to reach the registry",
4949 TEST_GIT_BUDGET,
4950 )
4951 .await;
4952 tokio::time::sleep(Duration::from_millis(100)).await;
4953 assert!(!reader.is_finished(), "the read waits on the run its slot names");
4954
4955 held.release();
4956 let answer = timeout(TEST_GIT_BUDGET, reader)
4957 .await
4958 .expect("the read answered")
4959 .expect("the read task finished");
4960 let _ = settle_background_fetches().await;
4961 crate::env::git_command::clear_git_point(dir.path());
4962
4963 assert_eq!(answer.state, BaseState::Current, "the joined run's check answers");
4964 assert_eq!(
4965 answer.base_ref.as_deref(),
4966 Some(rev_parse(upstream.path(), "main").as_str()),
4967 "with the base at the remote's tip",
4968 );
4969 assert_eq!(held.starts(), 1, "the read joined the kick rather than fetching again");
4970 }
4971
4972 #[test]
4976 fn a_run_without_a_check_gives_the_slot_back() {
4977 let dir = tempfile::tempdir().expect("tempdir");
4978 let now = Instant::now();
4979 assert!(
4980 claim_refresh_slot(dir.path(), now, WATCH_REFRESH_INTERVAL),
4981 "the fixture claims the slot",
4982 );
4983 let answered = BaseRefresh {
4984 state: BaseState::Current,
4985 base_ref: Some("abc123".to_owned()),
4986 error: None,
4987 cut: false,
4988 unanswered: false,
4989 at: Instant::now(),
4990 };
4991 give_back_slot(dir.path(), &answered);
4992 assert!(
4993 !claim_refresh_slot(dir.path(), Instant::now(), WATCH_REFRESH_INTERVAL),
4994 "a run that answered keeps its slot",
4995 );
4996
4997 let cut = BaseRefresh {
4998 state: BaseState::Unverified,
4999 base_ref: None,
5000 error: Some("the fetch did not finish inside the 30s it was given".to_owned()),
5001 cut: true,
5002 unanswered: false,
5003 at: Instant::now(),
5004 };
5005 give_back_slot(dir.path(), &cut);
5006 assert!(
5007 claim_refresh_slot(dir.path(), Instant::now(), WATCH_REFRESH_INTERVAL),
5008 "a run without a check gives the slot back",
5009 );
5010 }
5011
5012 #[tokio::test(flavor = "current_thread")]
5017 async fn a_cut_run_is_not_a_measurement_a_later_reading_reuses() {
5018 let (upstream, dir) = stale_base_fixture();
5019 let bin = tempfile::tempdir().expect("wrapper dir");
5020 let held = held_fetch_at(dir.path(), bin.path());
5021
5022 let cut = refresh_base_within(dir.path(), "origin/main", Duration::from_millis(300)).await;
5023 assert!(cut.cut, "the held fetch outlived the bound");
5024 assert!(
5025 last_base_measurement(dir.path(), "origin/main").is_none(),
5026 "a cut answer is not stored as the checkout's measurement",
5027 );
5028
5029 let reader = tokio::spawn({
5030 let dir = dir.path().to_path_buf();
5031 async move { refresh_base_for_display(&dir, "origin/main").await }
5032 });
5033 let answer = timeout(TEST_GIT_BUDGET, reader)
5034 .await
5035 .expect("the reading did not wait on its own fetch")
5036 .expect("the read task finished");
5037 assert_eq!(
5038 answer.state,
5039 BaseState::Unverified,
5040 "the cut answer is not a check the reading draws",
5041 );
5042 assert!(answer.error.is_none(), "and the cut's own words are not carried");
5043 wait_for_within(
5044 || held.starts() >= 2,
5045 "the reading kicks a refresh of its own",
5046 TEST_GIT_BUDGET,
5047 )
5048 .await;
5049
5050 held.release();
5051 let _ = settle_background_fetches().await;
5052 crate::env::git_command::clear_git_point(dir.path());
5053
5054 let next = refresh_base_for_display(dir.path(), "origin/main").await;
5055 assert_eq!(next.state, BaseState::Current, "the reading's own run measured the base");
5056 assert_eq!(
5057 next.base_ref.as_deref(),
5058 Some(rev_parse(upstream.path(), "main").as_str()),
5059 "with the base at the remote's tip",
5060 );
5061 assert_eq!(held.starts(), 2, "the cut run and the reading's own");
5062 }
5063
5064 #[tokio::test(flavor = "current_thread")]
5069 async fn the_explicit_refspec_writes_the_base_ref_whatever_the_remotes_own_says() {
5070 let upstream = tempfile::tempdir().expect("a bare remote");
5071 git_at(upstream.path(), &["init", "-q", "--bare"]);
5072 let dir = tempfile::tempdir().expect("tempdir");
5073 init_repo(&dir, "main");
5074 write_file(&dir, "base.txt", "base\n");
5075 commit_all(&dir, "X base");
5076 git_at(dir.path(), &["remote", "add", "origin", &upstream.path().to_string_lossy()]);
5077 git_at(dir.path(), &["push", "-q", "-u", "origin", "main"]);
5078 let stale = rev_parse_head(&dir);
5079 git_at(
5082 dir.path(),
5083 &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/upstream/*"],
5084 );
5085 write_file(&dir, "upstream.txt", "upstream\n");
5087 commit_all(&dir, "Y upstream");
5088 git_at(dir.path(), &["push", "-q", "origin", "main"]);
5089 git_at(dir.path(), &["update-ref", "refs/remotes/origin/main", &stale]);
5090
5091 let refresh = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5092
5093 assert_eq!(
5094 rev_parse(dir.path(), "refs/remotes/origin/main"),
5095 rev_parse(upstream.path(), "main"),
5096 "the explicit refspec moved the base ref the diff reads",
5097 );
5098 assert_eq!(refresh.state, BaseState::Current, "and the refreshed base is current");
5099 }
5100
5101 #[test]
5106 fn a_measurement_answers_a_reading_only_inside_the_store_window() {
5107 let now = Instant::now();
5108 let fresh = BaseMeasurement {
5113 at: now.checked_sub(Duration::from_secs(239)).expect("the test clock is past boot"),
5114 state: BaseState::Current,
5115 error: None,
5116 };
5117 let stale = BaseMeasurement {
5118 at: now.checked_sub(Duration::from_secs(241)).expect("the test clock is past boot"),
5119 state: BaseState::Current,
5120 error: None,
5121 };
5122
5123 assert!(measurement_in_window(&fresh, now), "a check 239 s old answers the read");
5124 assert!(!measurement_in_window(&stale, now), "one 241 s old does not");
5125 }
5126
5127 #[tokio::test(flavor = "current_thread")]
5133 async fn a_reading_ignores_an_expired_measurement_and_answers_unchecked() {
5134 let (_upstream, dir) = stale_base_fixture();
5135 let bin = tempfile::tempdir().expect("wrapper dir");
5136 let held = held_fetch_at(dir.path(), bin.path());
5137 remember_measurement(
5138 dir.path(),
5139 "origin/main",
5140 &BaseRefresh {
5141 state: BaseState::Current,
5142 base_ref: None,
5143 error: Some("a check from hours ago".to_owned()),
5144 cut: false,
5145 unanswered: false,
5146 at: Instant::now()
5147 .checked_sub(WATCH_REFRESH_INTERVAL + Duration::from_secs(60))
5148 .expect("the test clock is past boot"),
5149 },
5150 );
5151
5152 let answer = refresh_base_for_display(dir.path(), "origin/main").await;
5153 held.release();
5154 let _ = settle_background_fetches().await;
5155
5156 assert_eq!(answer.state, BaseState::Unverified, "an expired check is not drawn");
5157 assert!(answer.error.is_none(), "nor are its words carried: {answer:?}");
5158 assert!(answer.base_ref.is_some(), "the base is pinned to the local tip");
5159
5160 remember_measurement(
5162 dir.path(),
5163 "origin/main",
5164 &BaseRefresh {
5165 state: BaseState::Current,
5166 base_ref: None,
5167 error: None,
5168 cut: false,
5169 unanswered: false,
5170 at: Instant::now(),
5171 },
5172 );
5173 let fresh = refresh_base_for_display(dir.path(), "origin/main").await;
5174 crate::env::git_command::clear_git_point(dir.path());
5175 assert_eq!(fresh.state, BaseState::Current, "a check inside the window answers");
5176 }
5177
5178 #[tokio::test(flavor = "current_thread")]
5182 async fn a_measurement_answers_only_the_branch_it_measured() {
5183 let (_upstream, dir) = stale_base_fixture();
5184
5185 let _ = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5186 crate::env::git_command::clear_git_point(dir.path());
5187
5188 assert!(
5189 last_base_measurement(dir.path(), "origin/main").is_some(),
5190 "the refresh recorded the base it measured",
5191 );
5192 assert!(
5193 last_base_measurement(dir.path(), "origin/develop").is_none(),
5194 "and it must not answer a read for a different base",
5195 );
5196 }
5197
5198 #[tokio::test(flavor = "current_thread")]
5201 async fn readers_share_the_refresh_the_first_leads() {
5202 let (upstream, dir) = stale_base_fixture();
5203 let bin = tempfile::tempdir().expect("wrapper dir");
5204 let held = held_fetch_at(dir.path(), bin.path());
5205
5206 let mut readers = Vec::new();
5207 for _ in 0..3 {
5208 let dir = dir.path().to_path_buf();
5209 readers.push(tokio::spawn(async move {
5210 refresh_base_within(&dir, "origin/main", TEST_GIT_BUDGET).await
5211 }));
5212 }
5213 wait_for(|| held.starts() == 1, "the first reader's fetch to start").await;
5216 tokio::time::sleep(Duration::from_millis(100)).await;
5217 assert_eq!(held.starts(), 1, "one fetch for the burst, not one per reader");
5218
5219 held.release();
5220 for reader in readers {
5221 let read = timeout(TEST_GIT_BUDGET, reader)
5224 .await
5225 .expect("the read answered")
5226 .expect("the read task finished");
5227 assert_eq!(read.state, BaseState::Current, "every reader drew the refreshed base");
5228 assert!(!read.cut && !read.unanswered);
5229 }
5230 crate::env::git_command::clear_git_point(dir.path());
5231 assert_eq!(
5232 rev_parse(dir.path(), "refs/remotes/origin/main"),
5233 rev_parse(upstream.path(), "main"),
5234 "with the base at the remote's tip",
5235 );
5236 }
5237
5238 #[tokio::test(flavor = "current_thread")]
5241 async fn a_cut_fetch_names_the_budget_it_was_given() {
5242 let (_upstream, dir) = stale_base_fixture();
5243 let bin = tempfile::tempdir().expect("wrapper dir");
5244 let held = held_fetch_at(dir.path(), bin.path());
5245 let bound = Duration::from_millis(300);
5246
5247 let refresh = refresh_base_within(dir.path(), "origin/main", bound).await;
5248 held.release();
5249 let _ = settle_background_fetches().await;
5250 crate::env::git_command::clear_git_point(dir.path());
5251
5252 assert!(refresh.cut, "the run's bound ended the fetch");
5253 assert_eq!(
5254 refresh.error.as_deref(),
5255 Some("the fetch did not finish inside the 0.3s it was given"),
5256 "the run's own budget, not the nothing left of it",
5257 );
5258 }
5259
5260 #[tokio::test(flavor = "current_thread")]
5263 async fn two_scans_in_one_window_cost_one_fetch() {
5264 let (_upstream, dir) = stale_base_fixture();
5265 let bin = tempfile::tempdir().expect("wrapper dir");
5266 let counted = bin.path().join("fetches");
5267 let at = counted.display();
5268 let counting = format!("echo start >> '{at}'; exec git \"$@\"");
5269 write_wrapper(dir.path(), bin.path(), "counting-git", &[], &counting);
5270 let fetches = || {
5271 std::fs::read_to_string(&counted).map(|text| text.lines().count()).unwrap_or_default()
5272 };
5273
5274 let _ = scan_in_test(dir.path(), &HashMap::new(), None).await;
5275 let _ = settle_background_fetches().await;
5276 let _ = scan_in_test(dir.path(), &HashMap::new(), None).await;
5277 let _ = settle_background_fetches().await;
5278 crate::env::git_command::clear_git_point(dir.path());
5279
5280 assert_eq!(fetches(), 1, "the second scan's kick is throttled by the first");
5281 }
5282
5283 #[tokio::test(flavor = "current_thread")]
5287 async fn a_checkout_that_names_no_ssh_command_gets_forges() {
5288 if std::env::var_os("GIT_SSH_COMMAND").is_some() || std::env::var_os("GIT_SSH").is_some() {
5289 return;
5290 }
5291 let dir = tempfile::tempdir().expect("tempdir");
5292 let bin = tempfile::tempdir().expect("wrapper dir");
5293 init_repo(&dir, "main");
5294 without_machine_config_at(dir.path(), bin.path());
5295
5296 assert!(ssh_is_forges(dir.path()).await, "an ordinary checkout is forge's to bound");
5297 crate::env::git_command::clear_git_point(dir.path());
5298 }
5299
5300 #[tokio::test(flavor = "current_thread")]
5303 async fn a_checkouts_own_ssh_command_is_kept() {
5304 if std::env::var_os("GIT_SSH_COMMAND").is_some() || std::env::var_os("GIT_SSH").is_some() {
5305 return;
5306 }
5307 let dir = tempfile::tempdir().expect("tempdir");
5308 let bin = tempfile::tempdir().expect("wrapper dir");
5309 init_repo(&dir, "main");
5310 git_at(dir.path(), &["config", "core.sshCommand", "/bin/true"]);
5311 without_machine_config_at(dir.path(), bin.path());
5312
5313 assert!(!ssh_is_forges(dir.path()).await, "the checkout's command governs");
5314 crate::env::git_command::clear_git_point(dir.path());
5315 }
5316
5317 #[tokio::test(flavor = "current_thread")]
5322 async fn a_fetch_carries_forges_ssh_command_only_when_the_checkout_names_none() {
5323 if std::env::var_os("GIT_SSH_COMMAND").is_some() || std::env::var_os("GIT_SSH").is_some() {
5324 return;
5325 }
5326 let (_upstream, dir) = stale_base_fixture();
5327 let bin = tempfile::tempdir().expect("wrapper dir");
5328 let marker = bin.path().join("ssh-env");
5329 let at = marker.display();
5330 let capturing = format!(
5331 "echo \"ssh=${{GIT_SSH_COMMAND:-none}}\" >> '{at}'; \
5332 {ISOLATED_GIT_CONFIG} exec git \"$@\""
5333 );
5334 write_wrapper(dir.path(), bin.path(), "env-git", &[], &capturing);
5335
5336 let first = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5337 git_at(dir.path(), &["config", "core.sshCommand", "/bin/true"]);
5338 let second = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5339 crate::env::git_command::clear_git_point(dir.path());
5340
5341 assert_eq!(first.state, BaseState::Current);
5342 assert_eq!(second.state, BaseState::Current);
5343 let seen = std::fs::read_to_string(&marker).expect("the wrapper's record");
5344 let lines: Vec<&str> = seen.lines().collect();
5345 assert!(
5346 lines.first().is_some_and(|line| line.contains("ConnectTimeout=15")),
5347 "forge's bound is on the fetch of a checkout that names none: {seen:?}",
5348 );
5349 assert_eq!(
5350 lines.get(1).copied(),
5351 Some("ssh=none"),
5352 "and nothing is installed over the checkout's own command: {seen:?}",
5353 );
5354 }
5355
5356 #[test]
5359 fn a_remote_url_loses_its_credential_in_the_words_a_page_draws() {
5360 assert_eq!(
5361 redact(
5362 "fatal: unable to access 'https://user:s3cret@host.example/x.git/': Could not \
5363 resolve host"
5364 ),
5365 "fatal: unable to access 'https://<redacted>@host.example/x.git/': Could not resolve \
5366 host",
5367 );
5368 assert_eq!(
5369 redact("fatal: could not read from 'https://ghp_token@host.example/x'"),
5370 "fatal: could not read from 'https://<redacted>@host.example/x'",
5371 );
5372 assert_eq!(redact("fatal: no url here"), "fatal: no url here");
5373 assert_eq!(
5374 redact("fatal: 'https://host.example:8443/x' refused"),
5375 "fatal: 'https://host.example:8443/x' refused",
5376 "a port is not a credential",
5377 );
5378 }
5379
5380 #[tokio::test(flavor = "current_thread")]
5386 async fn a_failed_fetchs_credential_goes_before_the_words_are_capped() {
5387 let (_upstream, dir) = stale_base_fixture();
5388 let bin = tempfile::tempdir().expect("wrapper dir");
5389 let refused = format!(
5390 "printf '%s\\n' \"fatal: {}https://user:SECRETSECRET@host.example/x: {}\" >&2; exit 1",
5391 "x".repeat(170),
5392 "y".repeat(300),
5393 );
5394 write_wrapper(dir.path(), bin.path(), "credential-git", &[], &refused);
5395
5396 let refresh = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5397 crate::env::git_command::clear_git_point(dir.path());
5398
5399 let words = refresh.error.expect("the fetch failed");
5400 assert!(
5401 !words.contains("SECRET"),
5402 "the credential reached the words a page draws: {words:?}",
5403 );
5404 assert!(
5405 words.contains("<redacted>"),
5406 "and the words still say the URL carried one: {words:?}",
5407 );
5408 }
5409
5410 #[test]
5413 fn the_stderr_clip_cuts_on_a_character_boundary() {
5414 let text = format!("{}é", "a".repeat(STDERR_LOG_CAP - 1));
5415 let out = clipped(&text, STDERR_LOG_CAP);
5416 assert!(out.ends_with('…'), "the clip marks itself: {out:?}");
5417 assert!(!out.contains('é'), "the character the cap landed inside is dropped, not split");
5418 }
5419}