update pull request
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user