wip
This commit is contained in:
+122
-254
@@ -127,24 +127,13 @@ pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
|
||||
bail!("destination {} already exists", path.display());
|
||||
}
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for url in clone_urls {
|
||||
match clone(url, path) {
|
||||
Ok(repo) => {
|
||||
// The initial clone uses the default refspecs.
|
||||
// Also fetch the `refs/nostr/*` PR refs.
|
||||
fetch_all(&repo).ok();
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some(e) => Err(e).context("failed to clone from any mirror"),
|
||||
None => bail!("no clone URLs provided"),
|
||||
}
|
||||
try_each_url(clone_urls, "clone", |url| {
|
||||
let repo = clone(url, path)?;
|
||||
// The initial clone uses the default refspecs.
|
||||
// Also fetch the `refs/nostr/*` PR refs.
|
||||
fetch_all(&repo).ok();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
|
||||
@@ -199,25 +188,14 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||
///
|
||||
/// Unresolvable revisions are errors.
|
||||
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["merge-base", a, b])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git merge-base`")?;
|
||||
|
||||
match output.status.code() {
|
||||
// Exit 1 means no common ancestor, a valid outcome for a proposal.
|
||||
Some(1) => Ok(None),
|
||||
Some(0) => Ok(Some(
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_owned(),
|
||||
)),
|
||||
_ => bail!(
|
||||
"git merge-base failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
),
|
||||
let repo = open_with_cache(repo_path)?;
|
||||
let a = repo.rev_parse_single(a.as_bytes())?;
|
||||
let b = repo.rev_parse_single(b.as_bytes())?;
|
||||
match repo.merge_base(a, b) {
|
||||
Ok(id) => Ok(Some(id.to_string())),
|
||||
// No common ancestor, a valid outcome for a proposal.
|
||||
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,34 +226,6 @@ pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<S
|
||||
Ok(patch)
|
||||
}
|
||||
|
||||
/// Whether `patch` applies to the working tree of `repo_path` without modifying anything.
|
||||
pub fn patch_applies(repo_path: &Path, patch: &str) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.arg("apply")
|
||||
.args(["--check", "--3way", "--whitespace=nowarn", "-"])
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn `git apply --check`")?;
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin piped")
|
||||
.write_all(patch.as_bytes())?;
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"patch does not apply: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
|
||||
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
@@ -331,14 +281,7 @@ pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
///
|
||||
/// `None` when the repository has no commits yet, an unborn HEAD.
|
||||
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git rev-parse`")?;
|
||||
let output = git_output(repo_path, &["rev-parse", "HEAD"], "git rev-parse")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
@@ -371,13 +314,41 @@ pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||
// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
||||
// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
||||
let url = url
|
||||
.strip_prefix("grasp://")
|
||||
/// Rewrite a grasp server URL to the https URL the git transport actually uses.
|
||||
///
|
||||
/// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
||||
/// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
||||
fn transport_url(url: &str) -> String {
|
||||
url.strip_prefix("grasp://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.unwrap_or_else(|| url.to_owned());
|
||||
.unwrap_or_else(|| url.to_owned())
|
||||
}
|
||||
|
||||
/// Run `attempt` against each URL in `urls` until one succeeds.
|
||||
///
|
||||
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
|
||||
/// or `no clone URLs provided` when the list is empty.
|
||||
fn try_each_url<F>(urls: &[String], verb: &str, mut attempt: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(&str) -> Result<()>,
|
||||
{
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for url in urls {
|
||||
match attempt(url) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some(e) => Err(e).context(format!("failed to {verb} from any mirror")),
|
||||
None => bail!("no clone URLs provided"),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||
let url = transport_url(url);
|
||||
let url = gix::url::parse(url).context("invalid clone URL")?;
|
||||
|
||||
let mut prepare = gix::prepare_clone(url, path)?;
|
||||
@@ -435,44 +406,44 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<Str
|
||||
|
||||
/// Push the `main` branch of the repository at `repo_path` to a grasp server.
|
||||
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["push"])
|
||||
.arg(&url)
|
||||
.args(["refs/heads/main:refs/heads/main"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git push`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push to {base_url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
|
||||
///
|
||||
/// This mirrors an initialized repository's whole history.
|
||||
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`.
|
||||
fn push_refspecs(
|
||||
repo_path: &Path,
|
||||
base_url: &str,
|
||||
owner: &str,
|
||||
repo_id: &str,
|
||||
refspecs: &[&str],
|
||||
) -> Result<()> {
|
||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["push"])
|
||||
.arg(&url)
|
||||
.args(["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git push`")?;
|
||||
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
||||
args.push("push");
|
||||
args.push(&url);
|
||||
args.extend_from_slice(refspecs);
|
||||
|
||||
let output = git_output(repo_path, &args, "git push")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
@@ -480,7 +451,6 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) ->
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -489,14 +459,11 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) ->
|
||||
///
|
||||
/// `None` for a repository without commits.
|
||||
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["rev-list", "--max-parents=0", "HEAD"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git rev-list`")?;
|
||||
let output = git_output(
|
||||
repo_path,
|
||||
&["rev-list", "--max-parents=0", "HEAD"],
|
||||
"git rev-list",
|
||||
)?;
|
||||
|
||||
// An unborn HEAD with no commits yet makes `rev-list` fail.
|
||||
// There is no unique commit to report then.
|
||||
@@ -519,15 +486,8 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
if git_in(repo_path, &["remote", "get-url", "origin"]).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// `git remote add` already configures the default fetch refspec.
|
||||
git_in(repo_path, &["remote", "add", "origin", url])?;
|
||||
git_in(
|
||||
repo_path,
|
||||
&[
|
||||
"config",
|
||||
"remote.origin.fetch",
|
||||
"+refs/heads/*:refs/remotes/origin/*",
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -550,38 +510,19 @@ pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
///
|
||||
/// 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;
|
||||
try_each_url(urls, "fetch", |url| {
|
||||
let url = transport_url(url);
|
||||
|
||||
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`")?;
|
||||
let output = git_output(repo_path, &["fetch", &url, refspec], "git fetch")?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
bail!(
|
||||
"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`, sorted lexicographically, like `git for-each-ref`.
|
||||
@@ -592,14 +533,11 @@ pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
||||
// `for-each-ref` patterns match whole path components.
|
||||
// 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`")?;
|
||||
let output = git_output(
|
||||
repo_path,
|
||||
&["for-each-ref", "--format=%(refname)", pattern],
|
||||
"git for-each-ref",
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
@@ -659,14 +597,11 @@ pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
||||
///
|
||||
/// `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`")?;
|
||||
let output = git_output(
|
||||
workdir,
|
||||
&["remote", "get-url", "origin"],
|
||||
"git remote get-url",
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
@@ -717,18 +652,25 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Run a git command in `dir`, returning trimmed stdout.
|
||||
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
|
||||
///
|
||||
/// The terminal prompt is disabled so a credential request fails instead of hanging.
|
||||
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
/// `what` names the command in the spawn error.
|
||||
fn git_output(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git`")?;
|
||||
.with_context(|| format!("failed to spawn `{what}`"))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let output = git_output(dir, args, "git")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
@@ -1631,55 +1573,18 @@ fn take_quoted(input: &str) -> Option<(&str, &str)> {
|
||||
}
|
||||
|
||||
/// Undo git's C-style path quoting, `\NNN` octal escapes, `\"` and `\\`.
|
||||
///
|
||||
/// Delegates to gitoxide's C-style quote implementation, `gix::quote::ansi_c::undo`.
|
||||
/// It expects the surrounding double quotes, which are re-added around the interior.
|
||||
fn unquote_path(path: &str) -> Result<String> {
|
||||
if !path.contains('\\') {
|
||||
return Ok(path.to_owned());
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(path.len());
|
||||
let mut bytes = path.as_bytes();
|
||||
while let Some((&b, rest)) = bytes.split_first() {
|
||||
bytes = rest;
|
||||
if b == b'\\' {
|
||||
match bytes.split_first() {
|
||||
Some((&b'"', rest)) | Some((&b'\\', rest)) => {
|
||||
out.push(b);
|
||||
bytes = rest;
|
||||
}
|
||||
Some((&b'n', rest)) => {
|
||||
out.push(b'\n');
|
||||
bytes = rest;
|
||||
}
|
||||
Some((&b't', rest)) => {
|
||||
out.push(b'\t');
|
||||
bytes = rest;
|
||||
}
|
||||
Some((&d1, rest)) if (b'0'..=b'7').contains(&d1) => {
|
||||
let Some((&d2, rest)) = rest.split_first() else {
|
||||
bail!("malformed octal escape in quoted path");
|
||||
};
|
||||
let Some((&d3, rest)) = rest.split_first() else {
|
||||
bail!("malformed octal escape in quoted path");
|
||||
};
|
||||
if !(b'0'..=b'7').contains(&d2) || !(b'0'..=b'7').contains(&d3) {
|
||||
bail!("malformed octal escape in quoted path");
|
||||
}
|
||||
let code =
|
||||
(d1 - b'0') as u16 * 64 + (d2 - b'0') as u16 * 8 + (d3 - b'0') as u16;
|
||||
if code > u8::MAX as u16 {
|
||||
bail!("octal escape out of range in quoted path");
|
||||
}
|
||||
out.push(code as u8);
|
||||
bytes = rest;
|
||||
}
|
||||
_ => bail!("malformed escape in quoted path"),
|
||||
}
|
||||
} else {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8(out).context("invalid UTF-8 in quoted path")
|
||||
let quoted = format!("\"{path}\"");
|
||||
let (unquoted, _) = gix::quote::ansi_c::undo(gix::bstr::BStr::new(quoted.as_bytes()))
|
||||
.map_err(|e| anyhow::anyhow!("malformed quoted path: {e}"))?;
|
||||
String::from_utf8(unquoted.into_owned().to_vec()).context("invalid UTF-8 in quoted path")
|
||||
}
|
||||
|
||||
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||
@@ -1807,11 +1712,6 @@ pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
||||
repo_branches(&open_with_cache(workdir)?)
|
||||
}
|
||||
|
||||
/// Short names of tags, `refs/tags/*`, sorted alphabetically.
|
||||
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
|
||||
repo_tags(&open_with_cache(workdir)?)
|
||||
}
|
||||
|
||||
/// Short name of the branch HEAD points to, or `None` when detached.
|
||||
///
|
||||
/// Detached after checking out a tag or a commit directly.
|
||||
@@ -2291,38 +2191,6 @@ mod tests {
|
||||
assert!(format_patch_between(&path, "feature", "feature").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_applies_checks_without_modifying_the_tree() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("repo");
|
||||
let initial = init_repository(&path, "My Repo", "desc").expect("init");
|
||||
|
||||
git_run(&path, &["checkout", "-b", "feature"]);
|
||||
std::fs::write(path.join("feature.txt"), "feature\n").expect("write");
|
||||
commit_all(&gix::open(&path).expect("open"), "feature commit");
|
||||
let patch = format_patch_between(&path, &initial, "feature").expect("patch");
|
||||
|
||||
// A clone of the initial state accepts the series.
|
||||
let clone = dir.path().join("clone");
|
||||
git_run(
|
||||
dir.path(),
|
||||
&[
|
||||
"clone",
|
||||
"-q",
|
||||
path.to_str().unwrap(),
|
||||
clone.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
git_run(&clone, &["checkout", "-q", &initial]);
|
||||
assert!(patch_applies(&clone, &patch).is_ok());
|
||||
// The check must not have modified the working tree.
|
||||
assert!(!clone.join("feature.txt").exists());
|
||||
|
||||
// A conflicting file makes the same series fail the check.
|
||||
std::fs::write(clone.join("feature.txt"), "conflicting\n").expect("write");
|
||||
assert!(patch_applies(&clone, &patch).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_commit_ref_pushes_to_the_event_namespace() {
|
||||
// A bare server repository reachable via a `file://` URL.
|
||||
@@ -2977,7 +2845,7 @@ mod tests {
|
||||
assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted");
|
||||
|
||||
assert_eq!(
|
||||
worktree_tags(dir).expect("tags"),
|
||||
repo_tags(&repo).expect("tags"),
|
||||
vec!["v0.9".to_string(), "v1.0".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user