1use std::process::Output;
2use std::time::Duration;
3use tokio::process::Command;
4
5use super::aws::{self, AwsTempCreds};
6use super::types::{CredentialKind, Credentials, LsRemoteResult, RemoteHead};
7use crate::call::not_run;
8use crate::clone::https::HttpsAccess;
9use crate::clone::reason::hide;
10use crate::clone::redirect_config;
11use crate::clone::ssh::{drop_scp_scheme, SshKey, ONLY_SSH};
12use crate::errors::{CodecommitLsRemoteError, GitCallError, LsRemoteError};
13
14const LS_REMOTE_TIMEOUT_SECS: u64 = 20;
15
16const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";
18
19struct AwsRole {
20 arn: String,
21 external_id: String,
22 via: Option<String>,
23}
24
25trait Answer {
30 const SYMREF: bool;
31
32 fn timed_out() -> Self;
33 fn read(output: &Output, named: &str, secrets: &[&str]) -> Self;
34}
35
36impl Answer for LsRemoteResult {
37 const SYMREF: bool = false;
38
39 fn timed_out() -> Self {
40 Self {
41 commit: None,
42 error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
43 }
44 }
45
46 fn read(output: &Output, branch: &str, secrets: &[&str]) -> Self {
47 parse_ls_remote_output(output, branch, secrets)
48 }
49}
50
51impl Answer for RemoteHead {
52 const SYMREF: bool = true;
53
54 fn timed_out() -> Self {
55 Self {
56 branch: None,
57 error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
58 }
59 }
60
61 fn read(output: &Output, _named: &str, secrets: &[&str]) -> Self {
62 parse_symref_output(output, secrets)
63 }
64}
65
66fn branch_refs(branch: &str) -> [String; 2] {
69 [
70 format!("refs/heads/{branch}"),
71 format!("refs/tags/{branch}"),
72 ]
73}
74
75fn ls_remote_args(url: &str, pattern: &str, symref: bool) -> Vec<String> {
76 let mut args = vec!["ls-remote".to_owned()];
77 if symref {
78 args.push("--symref".to_owned());
79 }
80 args.extend(["--".to_owned(), url.to_owned(), pattern.to_owned()]);
81 args
82}
83
84async fn run_ls_remote<A: Answer>(
85 command: &mut Command,
86 named: &str,
87 secrets: &[&str],
88) -> Result<A, GitCallError> {
89 let Ok(output) = tokio::time::timeout(
90 Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
91 command.kill_on_drop(true).output(),
92 )
93 .await
94 else {
95 return Ok(A::timed_out());
96 };
97
98 let output = output.map_err(|source| not_run("ls-remote", source))?;
99 Ok(A::read(&output, named, secrets))
100}
101
102pub async fn ls_remote(
103 url: &str,
104 branch: &str,
105 credentials: Option<&Credentials>,
106 follow_redirects: bool,
107) -> anyhow::Result<LsRemoteResult> {
108 ask(url, branch, credentials, follow_redirects).await
109}
110
111pub async fn ssh_ls_remote(
112 url: &str,
113 branch: &str,
114 key: &str,
115) -> Result<LsRemoteResult, LsRemoteError> {
116 ls_remote_ssh(url, branch, key).await
117}
118
119pub async fn codecommit_ls_remote(
120 url: &str,
121 branch: &str,
122 arn: &str,
123 external_id: &str,
124 follow_redirects: bool,
125) -> Result<LsRemoteResult, CodecommitLsRemoteError> {
126 let role = AwsRole {
127 arn: arn.to_owned(),
128 external_id: external_id.to_owned(),
129 via: None,
130 };
131 ls_remote_codecommit(url, branch, &role, follow_redirects).await
132}
133
134pub async fn remote_head(
144 url: &str,
145 credentials: Option<&Credentials>,
146 follow_redirects: bool,
147) -> anyhow::Result<RemoteHead> {
148 ask(url, "HEAD", credentials, follow_redirects).await
149}
150
151async fn ask<A: Answer>(
152 url: &str,
153 pattern: &str,
154 credentials: Option<&Credentials>,
155 follow_redirects: bool,
156) -> anyhow::Result<A> {
157 match credentials.map(|c| &c.kind) {
158 Some(CredentialKind::Ssh { key }) => Ok(ls_remote_ssh(url, pattern, key).await?),
159 Some(CredentialKind::AwsRole {
160 arn,
161 external_id,
162 via,
163 }) => {
164 let role = AwsRole {
165 arn: arn.clone(),
166 external_id: external_id.clone(),
167 via: via.clone(),
168 };
169 Ok(ls_remote_codecommit(url, pattern, &role, follow_redirects).await?)
170 }
171 kind => {
172 let access = HttpsAccess::new(url, kind)?;
173 ls_remote_https(&access, pattern, follow_redirects).await
174 }
175 }
176}
177
178async fn ls_remote_ssh<A: Answer>(
179 url: &str,
180 pattern: &str,
181 key_b64: &str,
182) -> Result<A, LsRemoteError> {
183 let key = SshKey::write(key_b64)?;
184 let args = ls_remote_args(drop_scp_scheme(url), pattern, A::SYMREF);
185
186 let mut command = Command::new("git");
187 command
188 .args(&args)
189 .env("GIT_SSH_COMMAND", &key.command)
190 .env("GIT_ALLOW_PROTOCOL", ONLY_SSH);
191 Ok(run_ls_remote(&mut command, pattern, &[]).await?)
192}
193
194async fn ls_remote_https<A: Answer>(
195 access: &HttpsAccess,
196 pattern: &str,
197 follow_redirects: bool,
198) -> anyhow::Result<A> {
199 let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
200 args.extend(redirect_config(follow_redirects));
201 args.extend(ls_remote_args(access.url(), pattern, A::SYMREF));
202
203 let mut command = access.git();
204 command.args(&args);
205 Ok(run_ls_remote(&mut command, pattern, access.secret().as_slice()).await?)
206}
207
208async fn ls_remote_codecommit<A: Answer>(
209 url: &str,
210 pattern: &str,
211 role: &AwsRole,
212 follow_redirects: bool,
213) -> Result<A, CodecommitLsRemoteError> {
214 let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
215 Ok(ls_remote_with(url, pattern, &creds, follow_redirects).await?)
216}
217
218async fn ls_remote_with<A: Answer>(
219 url: &str,
220 pattern: &str,
221 creds: &AwsTempCreds,
222 follow_redirects: bool,
223) -> Result<A, GitCallError> {
224 let args = codecommit_ls_remote_args(url, pattern, A::SYMREF, follow_redirects);
225
226 let mut command = Command::new("git");
227 command
228 .args(&args)
229 .env("AWS_ACCESS_KEY_ID", &creds.access_key)
230 .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
231 .env("AWS_SESSION_TOKEN", &creds.session_token)
232 .env("AWS_DEFAULT_REGION", &creds.region);
233 let secrets = [creds.secret_key.as_str(), creds.session_token.as_str()];
234 run_ls_remote(&mut command, pattern, &secrets).await
235}
236
237fn codecommit_ls_remote_args(
240 url: &str,
241 pattern: &str,
242 symref: bool,
243 follow_redirects: bool,
244) -> Vec<String> {
245 let mut args = redirect_config(follow_redirects).to_vec();
246 args.extend(ls_remote_args(url, pattern, symref));
247 args
248}
249
250fn parse_ls_remote_output(output: &Output, branch: &str, secrets: &[&str]) -> LsRemoteResult {
251 if !output.status.success() {
252 let stderr = String::from_utf8_lossy(&output.stderr);
253 return LsRemoteResult {
254 commit: None,
255 error: Some(hide(&stderr, secrets)),
256 };
257 }
258
259 let stdout = String::from_utf8_lossy(&output.stdout);
260 let commit_of = |wanted: &str| {
261 stdout.lines().find_map(|line| {
262 let (commit, name) = line.split_once('\t')?;
263 (name == wanted).then_some(commit)
264 })
265 };
266 let commit = branch_refs(branch)
267 .iter()
268 .find_map(|wanted| commit_of(wanted))
269 .unwrap_or_default()
270 .to_owned();
271
272 LsRemoteResult {
273 commit: Some(commit),
274 error: None,
275 }
276}
277
278fn parse_symref_output(output: &Output, secrets: &[&str]) -> RemoteHead {
279 if !output.status.success() {
280 let stderr = String::from_utf8_lossy(&output.stderr);
281 return RemoteHead {
282 branch: None,
283 error: Some(hide(&stderr, secrets)),
284 };
285 }
286
287 let stdout = String::from_utf8_lossy(&output.stdout);
288 RemoteHead {
289 branch: stdout.lines().find_map(head_branch).map(str::to_owned),
290 error: None,
291 }
292}
293
294fn head_branch(line: &str) -> Option<&str> {
297 let (target, name) = line.strip_prefix("ref: ")?.split_once('\t')?;
298 if name != "HEAD" {
299 return None;
300 }
301 target
302 .strip_prefix("refs/heads/")
303 .filter(|branch| !branch.is_empty())
304}
305
306#[cfg(test)]
307mod tests {
308 use std::io::{BufRead, BufReader, Write};
309 use std::net::{TcpListener, TcpStream};
310 use std::sync::{Arc, Mutex};
311
312 use base64::Engine;
313
314 use super::{
315 codecommit_ls_remote_args, ls_remote, ls_remote_args, ls_remote_with,
316 parse_ls_remote_output, parse_symref_output, redirect_config, remote_head, run_ls_remote,
317 ssh_ls_remote, AwsTempCreds,
318 };
319 use crate::clone::https::HttpsAccess;
320 use crate::errors::{GitCallError, LsRemoteError};
321 use crate::types::{CredentialKind, Credentials, LsRemoteResult};
322
323 fn read_request(stream: &TcpStream) -> (String, Option<String>) {
325 let mut path = String::new();
326 let mut authorization = None;
327 let mut reader = BufReader::new(stream);
328 let mut line = String::new();
329 while reader.read_line(&mut line).is_ok_and(|read| read > 0) {
330 let header = line.trim_end();
331 if header.is_empty() {
332 break;
333 }
334 if path.is_empty() {
335 header
336 .split(' ')
337 .nth(1)
338 .unwrap_or_default()
339 .clone_into(&mut path);
340 }
341 if let Some(value) = header.strip_prefix("Authorization: ") {
342 authorization = Some(value.to_owned());
343 }
344 line.clear();
345 }
346 (path, authorization)
347 }
348
349 fn answer(redirect_to: Option<&str>, path: &str) -> String {
350 redirect_to.map_or_else(
351 || {
352 "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\n\
353 Content-Length: 0\r\nConnection: close\r\n\r\n"
354 .to_owned()
355 },
356 |host| {
357 format!(
358 "HTTP/1.1 302 Found\r\nLocation: {host}{path}\r\n\
359 Content-Length: 0\r\nConnection: close\r\n\r\n"
360 )
361 },
362 )
363 }
364
365 fn serve(redirect_to: Option<String>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
368 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
369 let port = listener.local_addr().expect("addr").port();
370 let seen = Arc::new(Mutex::new(Vec::new()));
371 let record = Arc::clone(&seen);
372 std::thread::spawn(move || {
373 for stream in listener.incoming().flatten() {
374 let (path, authorization) = read_request(&stream);
375 record.lock().expect("lock").push(authorization);
376 let answer = answer(redirect_to.as_deref(), &path);
377 let _ = (&stream).write_all(answer.as_bytes());
378 }
379 });
380 (format!("http://127.0.0.1:{port}"), seen)
381 }
382
383 fn token(token: &str) -> Credentials {
384 Credentials {
385 kind: CredentialKind::Token {
386 token: token.to_owned(),
387 is_pat: false,
388 oauth_type: String::new(),
389 },
390 }
391 }
392
393 fn pat(token: &str) -> Credentials {
394 Credentials {
395 kind: CredentialKind::Token {
396 token: token.to_owned(),
397 is_pat: true,
398 oauth_type: String::new(),
399 },
400 }
401 }
402
403 fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
404 use std::os::unix::process::ExitStatusExt;
405 std::process::Output {
406 status: std::process::ExitStatus::from_raw(code << 8),
407 stdout: stdout.as_bytes().to_vec(),
408 stderr: stderr.as_bytes().to_vec(),
409 }
410 }
411
412 #[test]
413 fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
414 assert_eq!(
415 redirect_config(false),
416 ["-c".to_owned(), "http.followRedirects=false".to_owned()]
417 );
418 assert_eq!(
419 redirect_config(true),
420 ["-c".to_owned(), "http.followRedirects=true".to_owned()]
421 );
422 }
423
424 #[test]
425 fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
426 let result = parse_ls_remote_output(&output(0, "", ""), "main", &[]);
427 assert_eq!(result.commit, Some(String::new()));
428 assert!(result.error.is_none());
429 }
430
431 #[test]
432 fn a_branch_is_read_by_its_full_name_and_before_a_tag_of_the_same_name() {
433 for (said, commit) in [
434 (
435 "aaa\trefs/heads/feature/main\nbbb\trefs/heads/main\n",
436 "bbb",
437 ),
438 ("ttt\trefs/tags/main\nbbb\trefs/heads/main\n", "bbb"),
439 ("ttt\trefs/tags/main\n", "ttt"),
440 ("aaa\trefs/heads/feature/main\n", ""),
441 ] {
442 let result = parse_ls_remote_output(&output(0, said, ""), "main", &[]);
443 assert_eq!(result.commit.as_deref(), Some(commit), "{said:?}");
444 }
445 }
446
447 #[test]
448 fn a_failure_reports_the_stderr_and_no_commit() {
449 let result =
450 parse_ls_remote_output(&output(128, "", "fatal: repository not found"), "main", &[]);
451 assert!(result.commit.is_none());
452 assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
453 }
454
455 #[tokio::test]
456 async fn a_token_reaches_its_host_and_stays_out_of_the_error() {
457 for (credentials, login) in [
458 (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
459 (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
460 ] {
461 let (host, seen) = serve(None);
462 let result = ls_remote(
463 &format!("{host}/org/repo.git"),
464 "main",
465 Some(&credentials),
466 false,
467 )
468 .await
469 .unwrap();
470
471 let sent = base64::engine::general_purpose::STANDARD.encode(login);
472 assert!(seen
473 .lock()
474 .unwrap()
475 .contains(&Some(format!("Basic {sent}"))));
476 let error = result.error.unwrap();
477 assert!(error.contains("Authentication failed"), "{error}");
478 assert!(!error.contains("Zm9v"), "{error}");
479 }
480 }
481
482 #[tokio::test]
483 async fn a_redirect_to_another_host_carries_no_credential() {
484 for credentials in [token("tok123"), pat("tok123")] {
485 let (other, other_seen) = serve(None);
486 let other = other.replace("127.0.0.1", "localhost");
487 let (host, _) = serve(Some(other));
488 let result = ls_remote(
489 &format!("{host}/org/repo.git"),
490 "main",
491 Some(&credentials),
492 true,
493 )
494 .await
495 .unwrap();
496
497 assert!(result.error.is_some());
498 let other_seen = other_seen.lock().unwrap().clone();
499 assert!(!other_seen.is_empty());
500 assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
501 }
502 }
503
504 #[tokio::test]
505 async fn a_helper_the_caller_configured_never_sees_the_credential() {
506 let (host, _) = serve(None);
507 let calls = tempfile::NamedTempFile::new().unwrap();
508 let access =
509 HttpsAccess::new(&format!("{host}/org/repo.git"), Some(&token("tok123").kind)).unwrap();
510 let mut command = access.git();
511 command
512 .env(
513 "GIT_CONFIG_PARAMETERS",
514 format!(
515 "'credential.helper'='!f() {{ echo $1 >> {}; }}; f'",
516 calls.path().display()
517 ),
518 )
519 .args(["ls-remote", "--", access.url(), "main"]);
520 run_ls_remote::<LsRemoteResult>(&mut command, "main", access.secret().as_slice())
521 .await
522 .unwrap();
523
524 assert_eq!(std::fs::read_to_string(calls.path()).unwrap(), "");
525 }
526
527 #[test]
528 fn a_failure_hides_the_credential_a_server_echoes() {
529 let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
530 let result = parse_ls_remote_output(&output(128, "", said), "main", &["tok123"]);
531 assert_eq!(
532 result.error.as_deref(),
533 Some("remote: token [REDACTED] is not valid\nfatal: Authentication failed\n")
534 );
535 }
536
537 #[test]
538 fn a_codecommit_url_lets_git_check_the_certificate() {
539 for symref in [false, true] {
540 let args =
541 codecommit_ls_remote_args("codecommit::us-east-1://repo", "main", symref, false);
542 assert!(!args.contains(&"http.sslVerify=false".to_owned()));
543 }
544 }
545
546 #[tokio::test]
547 async fn a_codecommit_ls_remote_keeps_the_session_out_of_what_git_says() {
548 let tmp = tempfile::tempdir().unwrap();
549 let url = format!("{}/a-secret-key/a-token", tmp.path().display());
551 let creds = AwsTempCreds {
552 access_key: "an-access-key".to_owned(),
553 secret_key: "a-secret-key".to_owned(),
554 session_token: "a-token".to_owned(),
555 region: "us-east-1".to_owned(),
556 };
557
558 let answer: LsRemoteResult = ls_remote_with(&url, "main", &creds, false)
559 .await
560 .expect("git ran");
561
562 let said = answer.error.expect("git refused");
563 assert!(said.contains("[REDACTED]/[REDACTED]"), "{said}");
564 }
565
566 #[test]
567 fn symbolic_refs_are_asked_for_only_when_wanted_and_before_the_url() {
568 assert_eq!(
569 ls_remote_args("https://h/r", "HEAD", true),
570 ["ls-remote", "--symref", "--", "https://h/r", "HEAD"]
571 );
572 assert_eq!(
573 ls_remote_args("https://h/r", "main", false),
574 ["ls-remote", "--", "https://h/r", "main"]
575 );
576 }
577
578 fn repository(branch: &str, commits: usize) -> tempfile::TempDir {
581 let dir = tempfile::tempdir().unwrap();
582 let git = |args: &[&str]| {
583 let status = std::process::Command::new("git")
584 .arg("-C")
585 .arg(dir.path())
586 .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
587 .args(args)
588 .output()
589 .unwrap()
590 .status;
591 assert!(status.success(), "git {args:?}");
592 };
593 git(&["init", "--quiet", "--initial-branch", branch]);
594 for _ in 0..commits {
595 git(&["commit", "--quiet", "--allow-empty", "--message", "c"]);
596 }
597 dir
598 }
599
600 #[test]
601 fn the_branch_head_names_is_read_without_its_prefix() {
602 let result = parse_symref_output(
603 &output(0, "ref: refs/heads/release/2.0\tHEAD\nabc123\tHEAD\n", ""),
604 &[],
605 );
606 assert_eq!(result.branch.as_deref(), Some("release/2.0"));
607 assert!(result.error.is_none());
608 }
609
610 #[test]
611 fn a_head_that_names_no_branch_is_no_branch_and_no_error() {
612 for said in [
613 "",
614 "abc123\tHEAD\n",
615 "ref: refs/tags/v1\tHEAD\n",
616 "ref: refs/heads/\tHEAD\n",
617 "ref: refs/heads/main\trefs/remotes/origin/HEAD\n",
618 ] {
619 let result = parse_symref_output(&output(0, said, ""), &[]);
620 assert!(result.branch.is_none(), "{said:?}");
621 assert!(result.error.is_none(), "{said:?}");
622 }
623 }
624
625 #[test]
626 fn a_failed_head_read_reports_the_stderr_with_the_credential_hidden() {
627 let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
628 let result = parse_symref_output(&output(128, "", said), &["tok123"]);
629 assert!(result.branch.is_none());
630 assert_eq!(
631 result.error.as_deref(),
632 Some("remote: token [REDACTED] is not valid\nfatal: Authentication failed\n")
633 );
634 }
635
636 #[tokio::test]
637 async fn a_remote_answers_the_branch_its_head_names() {
638 let remote = repository("trunk", 1);
639 let url = remote.path().to_str().unwrap();
640
641 let result = remote_head(url, None, false).await.unwrap();
642
643 assert_eq!(result.branch.as_deref(), Some("trunk"));
644 assert!(result.error.is_none());
645 }
646
647 #[tokio::test]
648 async fn an_empty_remote_names_no_branch() {
649 let remote = repository("trunk", 0);
650 let url = remote.path().to_str().unwrap();
651
652 let result = remote_head(url, None, false).await.unwrap();
653
654 assert!(result.branch.is_none());
655 assert!(result.error.is_none());
656 }
657
658 #[tokio::test]
659 async fn a_branch_is_not_found_by_a_ref_that_only_ends_in_its_name() {
660 let remote = repository("trunk", 1);
661 let url = remote.path().to_str().unwrap();
662 let moved = std::process::Command::new("git")
663 .arg("-C")
664 .arg(remote.path())
665 .args(["update-ref", "refs/heads/feature/dev", "trunk"])
666 .status()
667 .unwrap();
668 assert!(moved.success());
669
670 let found = ls_remote(url, "trunk", None, false).await.unwrap();
671 let missing = ls_remote(url, "dev", None, false).await.unwrap();
672
673 assert_eq!(found.commit.unwrap().len(), 40);
674 assert_eq!(missing.commit.as_deref(), Some(""));
675 }
676
677 #[tokio::test]
678 async fn an_ssh_ls_remote_with_a_nul_in_the_branch_never_runs_git() {
679 let key = base64::engine::general_purpose::STANDARD.encode("not a real key");
680
681 let nul = ssh_ls_remote("git@example.com:org/repo.git", "ma\0in", &key).await;
682
683 assert!(matches!(
684 nul,
685 Err(LsRemoteError::Git(GitCallError::NullByte {
686 command: "ls-remote"
687 }))
688 ));
689 }
690
691 #[tokio::test]
692 async fn a_key_reads_no_head_from_a_url_that_is_not_ssh() {
693 let remote = repository("trunk", 1);
694 let url = remote.path().to_str().unwrap();
695 let key = Credentials {
696 kind: CredentialKind::Ssh {
697 key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
698 },
699 };
700
701 let result = remote_head(url, Some(&key), false).await.unwrap();
702
703 assert!(result.branch.is_none());
704 assert!(result.error.is_some());
705 }
706
707 #[tokio::test]
708 async fn a_head_read_takes_the_token_to_its_host_and_out_of_the_error() {
709 for (credentials, login) in [
710 (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
711 (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
712 ] {
713 let (host, seen) = serve(None);
714 let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), false)
715 .await
716 .unwrap();
717
718 let sent = base64::engine::general_purpose::STANDARD.encode(login);
719 assert!(seen
720 .lock()
721 .unwrap()
722 .contains(&Some(format!("Basic {sent}"))));
723 assert!(result.branch.is_none());
724 let error = result.error.unwrap();
725 assert!(error.contains("Authentication failed"), "{error}");
726 assert!(!error.contains("Zm9v"), "{error}");
727 }
728 }
729
730 #[tokio::test]
731 async fn a_head_read_redirected_to_another_host_carries_no_credential() {
732 for credentials in [token("tok123"), pat("tok123")] {
733 let (other, other_seen) = serve(None);
734 let other = other.replace("127.0.0.1", "localhost");
735 let (host, _) = serve(Some(other));
736 let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), true)
737 .await
738 .unwrap();
739
740 assert!(result.error.is_some());
741 let other_seen = other_seen.lock().unwrap().clone();
742 assert!(!other_seen.is_empty());
743 assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
744 }
745 }
746}