update pull request

This commit is contained in:
2026-09-03 10:44:38 +07:00
parent 33cbe42551
commit 01f0540726
16 changed files with 3211 additions and 248 deletions
+279
View File
@@ -20,6 +20,11 @@ impl GitCache {
Self { root }
}
/// The root directory holding the mirror clones.
pub fn root(&self) -> &Path {
&self.root
}
/// Local path of the clone for a repository.
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
self.root
@@ -523,6 +528,132 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
Ok(())
}
/// Fetch `refspec` (e.g. `+refs/heads/*:refs/fork/<owner>/<id>/*`) into the
/// repository at `repo_path` from the first working URL in `urls`, like
/// [`clone_repo`]: `grasp://` URLs are rewritten to `https://`, the
/// terminal prompt is disabled, and when no URL works the last error is
/// returned. Never touches the checked-out refs or the worktree.
pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> {
let mut last_err: Option<anyhow::Error> = None;
for url in urls {
let url = url
.strip_prefix("grasp://")
.map(|rest| format!("https://{rest}"))
.unwrap_or_else(|| url.to_owned());
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["fetch"])
.arg(&url)
.arg(refspec)
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git fetch`")?;
if output.status.success() {
return Ok(());
}
last_err = Some(anyhow::anyhow!(
"git fetch from {url} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
match last_err {
Some(e) => Err(e).context("failed to fetch from any mirror"),
None => bail!("no clone URLs provided"),
}
}
/// Full ref names under `prefix` (e.g. `refs/fork/<owner>/<id>`), sorted
/// lexicographically, like `git for-each-ref`. An empty list when nothing
/// matches.
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
// `for-each-ref` patterns match whole path components, so a trailing
// slash would silently change what is matched.
let pattern = prefix.trim_end_matches('/');
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["for-each-ref", "--format=%(refname)", pattern])
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git for-each-ref`")?;
if !output.status.success() {
bail!(
"git for-each-ref failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_owned)
.filter(|name| !name.is_empty())
.collect())
}
/// Delete every ref under `prefix` (e.g. `refs/fork/<owner>/<id>`) of the
/// repository at `repo_path`, so a stale import can be pruned before a
/// re-import. No-op when nothing matches.
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
let refs = refs_with_prefix(repo_path, prefix)?;
if refs.is_empty() {
return Ok(());
}
let mut child = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["update-ref", "--stdin"])
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn `git update-ref --stdin`")?;
for name in refs {
child
.stdin
.as_mut()
.expect("stdin piped")
.write_all(format!("delete {name}\n").as_bytes())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
bail!(
"git update-ref failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
/// The URL of the `origin` remote of the repository at `workdir`, or `None`
/// when it has no `origin` yet.
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
let output = Command::new("git")
.arg("-C")
.arg(workdir)
.args(["remote", "get-url", "origin"])
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git remote get-url`")?;
if !output.status.success() {
return Ok(None);
}
let url = String::from_utf8_lossy(&output.stdout);
Ok((!url.trim().is_empty()).then(|| url.trim().to_owned()))
}
/// Run a git command in `dir`, returning trimmed stdout. The terminal prompt
/// is disabled so a credential request fails instead of hanging.
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
@@ -2321,6 +2452,154 @@ mod tests {
);
}
#[test]
fn origin_url_reads_the_remote_or_reports_none() {
let (dir, _repo) = fixture(&[("a.txt", b"one")]);
commit_all(&_repo, "initial");
let dir = dir.path();
// No remote configured yet.
assert_eq!(origin_url(dir).expect("read"), None);
ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add");
assert_eq!(
origin_url(dir).expect("read").as_deref(),
Some("https://gitnostr.com/npub1test/repo.git")
);
}
#[test]
fn fetch_repo_refs_imports_heads_under_a_prefix() {
let dir = tempfile::tempdir().expect("tempdir");
// A bare "base" server holding the initial commit, like a grasp
// server's `{base}/{owner}/{repo-id}.git` layout.
let base_server = dir.path().join("npub1base").join("base.git");
std::fs::create_dir_all(base_server.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&base_server)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]);
commit_all(&upstream_repo, "initial");
let upstream_path = upstream_dir.path();
let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial");
push_all(
upstream_path,
&format!("file://{}", dir.path().display()),
"npub1base",
"base",
)
.expect("push");
// The base mirror: a plain clone of the base server.
let base_url = format!("file://{}", base_server.display());
let mirror = dir.path().join("mirror");
git_run(
dir.path(),
&["clone", "-q", &base_url, mirror.to_str().unwrap()],
);
// The fork server: the same initial commit plus a feature commit on
// its own `feature` branch.
let fork_work = dir.path().join("fork-work");
git_run(
dir.path(),
&["clone", "-q", &base_url, fork_work.to_str().unwrap()],
);
git_run(&fork_work, &["checkout", "-b", "feature"]);
std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write");
commit_all(&gix::open(&fork_work).expect("open"), "feature commit");
let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip");
let fork_server = dir.path().join("npub1fork").join("fork.git");
std::fs::create_dir_all(fork_server.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&fork_server)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
push_commit_ref(
&fork_work,
&format!("file://{}", fork_server.display()),
&tip,
"refs/heads/feature",
)
.expect("push");
// Import the fork's heads into the mirror under a private prefix;
// the first (dead) URL is skipped, the second works.
let dead = format!("file://{}/missing.git", dir.path().display());
fetch_repo_refs(
&mirror,
&[dead, format!("file://{}", fork_server.display())],
"+refs/heads/*:refs/fork/npub1fork/fork/*",
)
.expect("fetch");
// The imported refs are listed under the prefix only.
assert_eq!(
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
vec!["refs/fork/npub1fork/fork/feature"]
);
// Nothing leaked into the normal ref namespaces.
assert_eq!(
refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"),
Vec::<String>::new()
);
// The mirror can now range across both histories: the fork point is
// the shared initial commit, and the proposal covers the fork commit.
assert_eq!(
merge_base(
&mirror,
"refs/remotes/origin/main",
"refs/fork/npub1fork/fork/feature",
)
.expect("merge base")
.as_deref(),
Some(initial.as_str())
);
let patch = format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature")
.expect("patch");
assert!(patch.contains("Subject: [PATCH] feature commit"));
assert!(patch.contains("feature.txt"));
// Pruning the prefix removes the import again.
delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete");
assert_eq!(
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
Vec::<String>::new()
);
}
#[test]
fn fetch_repo_refs_fails_when_every_url_fails() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = _dir.path();
let dead = format!("file://{}/missing.git", dir.display());
let err = fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*")
.expect_err("all URLs fail");
assert!(err.to_string().contains("failed to fetch"));
// Without any URL there is nothing to try.
let err = fetch_repo_refs(dir, &[], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs");
assert!(err.to_string().contains("no clone URLs"));
}
#[test]
fn delete_refs_with_prefix_is_a_noop_without_matches() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop");
}
/// Run a git command in `dir`, asserting success.
fn git_run(dir: &Path, args: &[&str]) {
let status = Command::new("git")