c42f11b86c
Gitea builds ssh_url from its own SSH_DOMAIN, which is whatever the
server was told to advertise. A server announcing a public hostname
whose SSH port is only open on the LAN hands out a remote that connects
from nowhere — and because "Create and attach" does not push, the
failure surfaces later, at the first Sync:
ssh: connect to host <wan-name> port 2228: Connection refused
The app already knows an address that works, having just made an API
call over it, so the SSH host is taken from the configured server URL
instead. The port Gitea reports is kept, as are the user and path, and
both URL shapes are handled (ssh://user@host:port/path and the scp-like
user@host:path). Setting the server to a tailnet address therefore gets
a remote that works on and off the LAN alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
473 lines
17 KiB
Rust
473 lines
17 KiB
Rust
//! Creating a repository on a Gitea server.
|
|
//!
|
|
//! This used to be a cookiecutter template's job: its post-generation hook read
|
|
//! `GITEA_*` out of the environment and called the API itself. That only ever
|
|
//! covered projects made through **New project**, and when it went wrong it did
|
|
//! so inside someone else's Python, where this app could only report that a
|
|
//! hook had exited non-zero.
|
|
//!
|
|
//! So the app now talks to Gitea directly. Everything here is one HTTP call and
|
|
//! the parsing around it; attaching the result to a workspace is
|
|
//! [`crate::gitsync::set_origin`]'s job.
|
|
|
|
use std::time::Duration;
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// What to ask the server to create.
|
|
pub struct NewRepo {
|
|
/// Repository name, as it will appear under the owner.
|
|
pub name: String,
|
|
/// Optional description; blank is fine.
|
|
pub description: String,
|
|
/// Whether the repository is private.
|
|
pub private: bool,
|
|
}
|
|
|
|
/// The repository the server ended up with — created just now, or already there.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Repo {
|
|
/// `owner/name`, as Gitea reports it.
|
|
pub full_name: String,
|
|
/// SSH clone URL, which is what a key-based setup wants.
|
|
pub ssh_url: String,
|
|
/// HTTPS clone URL, which needs credentials on every push.
|
|
pub clone_url: String,
|
|
/// Page to open in a browser.
|
|
pub html_url: String,
|
|
/// False when the repository was already there and we attached to it. The
|
|
/// distinction matters to the message shown, not to what happens next.
|
|
pub created: bool,
|
|
}
|
|
|
|
impl Repo {
|
|
/// The URL to point `origin` at.
|
|
pub fn remote_url(&self, use_ssh: bool) -> &str {
|
|
if use_ssh {
|
|
&self.ssh_url
|
|
} else {
|
|
&self.clone_url
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What Gitea sends back for a repository. Only the fields used here are named;
|
|
/// the reply carries a few dozen more.
|
|
#[derive(Deserialize)]
|
|
struct RepoReply {
|
|
full_name: String,
|
|
ssh_url: String,
|
|
clone_url: String,
|
|
html_url: String,
|
|
}
|
|
|
|
impl RepoReply {
|
|
fn into_repo(self, created: bool) -> Repo {
|
|
Repo {
|
|
full_name: self.full_name,
|
|
ssh_url: self.ssh_url,
|
|
clone_url: self.clone_url,
|
|
html_url: self.html_url,
|
|
created,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The API root for a server URL, or an error naming what is missing.
|
|
///
|
|
/// Accepts the server URL with or without a trailing slash, and tolerates the
|
|
/// `/api/v1` suffix already being there — it is the sort of thing that gets
|
|
/// pasted in, and silently producing `/api/v1/api/v1` would be a 404 with no
|
|
/// hint as to why.
|
|
pub fn api_base(server: &str) -> Result<String, String> {
|
|
let base = server.trim().trim_end_matches('/');
|
|
if base.is_empty() {
|
|
return Err(
|
|
"No Gitea server set (open Settings ▸ New project and fill in the \
|
|
Gitea rows)"
|
|
.to_string(),
|
|
);
|
|
}
|
|
if !base.starts_with("http://") && !base.starts_with("https://") {
|
|
return Err(format!("Gitea URL must start with http:// or https:// — got {base}"));
|
|
}
|
|
let base = base.trim_end_matches("/api/v1").trim_end_matches('/');
|
|
Ok(format!("{base}/api/v1"))
|
|
}
|
|
|
|
/// Turn a repository name into what Gitea will actually call it.
|
|
///
|
|
/// Gitea replaces characters it does not allow, so a name typed with spaces
|
|
/// comes back different from what was asked for; normalising up front means the
|
|
/// URL that gets written into `origin` is the one the server agreed to.
|
|
pub fn normalize_name(name: &str) -> String {
|
|
let cleaned: String = name
|
|
.trim()
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
|
c
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect();
|
|
// Collapse the runs a replacement can leave behind, and never lead or
|
|
// trail with punctuation.
|
|
let mut out = String::with_capacity(cleaned.len());
|
|
for c in cleaned.chars() {
|
|
if c == '-' && out.ends_with('-') {
|
|
continue;
|
|
}
|
|
out.push(c);
|
|
}
|
|
out.trim_matches(|c| c == '-' || c == '.').to_string()
|
|
}
|
|
|
|
/// The host in a URL: no scheme, no credentials, no port, no path.
|
|
pub fn host_of(url: &str) -> &str {
|
|
let s = url.trim();
|
|
let s = s.split_once("://").map_or(s, |(_, rest)| rest);
|
|
let s = s.split(['/', '?', '#']).next().unwrap_or(s);
|
|
let s = s.rsplit_once('@').map_or(s, |(_, host)| host);
|
|
// Only a numeric tail is a port; anything else is part of the host.
|
|
match s.rsplit_once(':') {
|
|
Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
|
|
_ => s,
|
|
}
|
|
}
|
|
|
|
/// Rewrite the host of an SSH clone URL to `host`, keeping user, port and path.
|
|
///
|
|
/// Gitea builds `ssh_url` from its own `SSH_DOMAIN`, which is whatever the
|
|
/// server was configured to advertise — not necessarily an address the machine
|
|
/// running this app can reach. A server told to advertise a public hostname
|
|
/// whose SSH port is only open on the LAN hands out a URL that connects from
|
|
/// nowhere, and the failure only shows up at the first push.
|
|
///
|
|
/// The app already knows one address that works, because it just made an API
|
|
/// call over it, so the created remote is pointed at that host instead. Both
|
|
/// URL shapes git accepts are handled: `ssh://user@host:port/path` and the
|
|
/// scp-like `user@host:path`.
|
|
pub fn rehost_ssh_url(ssh_url: &str, host: &str) -> String {
|
|
let host = host.trim();
|
|
if host.is_empty() || ssh_url.trim().is_empty() {
|
|
return ssh_url.to_string();
|
|
}
|
|
if let Some(rest) = ssh_url.strip_prefix("ssh://") {
|
|
let (authority, path) = match rest.find('/') {
|
|
Some(i) => rest.split_at(i),
|
|
None => (rest, ""),
|
|
};
|
|
let mut out = String::from("ssh://");
|
|
if let Some((user, _)) = authority.rsplit_once('@') {
|
|
out.push_str(user);
|
|
out.push('@');
|
|
}
|
|
out.push_str(host);
|
|
if let Some(port) = port_of(authority) {
|
|
out.push(':');
|
|
out.push_str(port);
|
|
}
|
|
out.push_str(path);
|
|
return out;
|
|
}
|
|
// scp-like: everything before the first colon is [user@]host.
|
|
if let Some((authority, path)) = ssh_url.split_once(':') {
|
|
let mut out = String::new();
|
|
if let Some((user, _)) = authority.rsplit_once('@') {
|
|
out.push_str(user);
|
|
out.push('@');
|
|
}
|
|
out.push_str(host);
|
|
out.push(':');
|
|
out.push_str(path);
|
|
return out;
|
|
}
|
|
ssh_url.to_string()
|
|
}
|
|
|
|
/// The numeric port of an `[user@]host[:port]` authority, if it has one.
|
|
fn port_of(authority: &str) -> Option<&str> {
|
|
let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
|
match hostport.rsplit_once(':') {
|
|
Some((_, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => {
|
|
Some(port)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Create `repo` under the token's own account, returning what the server holds.
|
|
///
|
|
/// A name that is already taken is not treated as a failure: the existing
|
|
/// repository is fetched and returned with `created: false`, so pointing a
|
|
/// workspace at a repository made on an earlier attempt works the same as
|
|
/// making a fresh one.
|
|
pub fn create(server: &str, user: &str, token: &str, repo: &NewRepo) -> Result<Repo, String> {
|
|
let api = api_base(server)?;
|
|
let token = token.trim();
|
|
if token.is_empty() {
|
|
return Err(
|
|
"No Gitea token set (open Settings ▸ New project and fill in the \
|
|
Gitea rows)"
|
|
.to_string(),
|
|
);
|
|
}
|
|
let name = normalize_name(&repo.name);
|
|
if name.is_empty() {
|
|
return Err("Give the repository a name".to_string());
|
|
}
|
|
|
|
let agent = ureq::AgentBuilder::new()
|
|
.timeout(Duration::from_secs(30))
|
|
.build();
|
|
let url = format!("{api}/user/repos");
|
|
let body = serde_json::json!({
|
|
"name": name,
|
|
"description": repo.description.trim(),
|
|
"private": repo.private,
|
|
// The workspace already has (or will have) its own history; letting the
|
|
// server commit a README first would mean the first push is rejected as
|
|
// a non-fast-forward.
|
|
"auto_init": false,
|
|
});
|
|
|
|
// Serialised here rather than through `send_json`, which is behind a ureq
|
|
// feature this build does not enable; serde_json is already a dependency.
|
|
let result = agent
|
|
.post(&url)
|
|
.set("Authorization", &format!("token {token}"))
|
|
.set("Content-Type", "application/json")
|
|
.send_string(&body.to_string());
|
|
|
|
let repo = match result {
|
|
Ok(resp) => {
|
|
let text = resp
|
|
.into_string()
|
|
.map_err(|e| format!("Could not read Gitea's reply: {e}"))?;
|
|
parse_repo(&text, true)
|
|
}
|
|
// Already there: attach to it rather than making the user rename.
|
|
Err(ureq::Error::Status(409, _)) => fetch(&agent, &api, token, user, &name),
|
|
Err(e) => Err(friendly_error(&api, e)),
|
|
};
|
|
// Point SSH at the host the API call just succeeded over, rather than
|
|
// whatever the server advertises — see `rehost_ssh_url`.
|
|
repo.map(|mut repo| {
|
|
repo.ssh_url = rehost_ssh_url(&repo.ssh_url, host_of(server));
|
|
repo
|
|
})
|
|
}
|
|
|
|
/// Read back a repository that already exists.
|
|
fn fetch(
|
|
agent: &ureq::Agent,
|
|
api: &str,
|
|
token: &str,
|
|
user: &str,
|
|
name: &str,
|
|
) -> Result<Repo, String> {
|
|
let owner = user.trim();
|
|
if owner.is_empty() {
|
|
return Err(format!(
|
|
"A repository named {name} already exists, but there is no Gitea user \
|
|
set to look it up under"
|
|
));
|
|
}
|
|
let url = format!("{api}/repos/{owner}/{name}");
|
|
let resp = agent
|
|
.get(&url)
|
|
.set("Authorization", &format!("token {token}"))
|
|
.call()
|
|
.map_err(|e| friendly_error(api, e))?;
|
|
let text = resp
|
|
.into_string()
|
|
.map_err(|e| format!("Could not read Gitea's reply: {e}"))?;
|
|
parse_repo(&text, false)
|
|
}
|
|
|
|
/// Pull the clone URLs out of a repository reply.
|
|
fn parse_repo(body: &str, created: bool) -> Result<Repo, String> {
|
|
let reply: RepoReply = serde_json::from_str(body)
|
|
.map_err(|e| format!("Unexpected reply from Gitea: {e}"))?;
|
|
Ok(reply.into_repo(created))
|
|
}
|
|
|
|
/// Turn a `ureq` error into something that says what to change.
|
|
///
|
|
/// The status codes here are the ones a misconfiguration actually produces, and
|
|
/// each has a different fix — a bad token and a bad URL are not distinguishable
|
|
/// from "it didn't work".
|
|
fn friendly_error(api: &str, err: ureq::Error) -> String {
|
|
match err {
|
|
ureq::Error::Status(401, _) => {
|
|
"Gitea rejected the token (401) — check the token in Settings ▸ New project"
|
|
.to_string()
|
|
}
|
|
ureq::Error::Status(403, _) => {
|
|
"Gitea refused the request (403) — the token needs write access to \
|
|
repositories"
|
|
.to_string()
|
|
}
|
|
ureq::Error::Status(404, _) => {
|
|
format!("No Gitea API at {api} (404) — check the server URL")
|
|
}
|
|
ureq::Error::Status(422, resp) => {
|
|
let detail = resp
|
|
.into_string()
|
|
.ok()
|
|
.and_then(|b| serde_json::from_str::<serde_json::Value>(&b).ok())
|
|
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(str::to_string))
|
|
.unwrap_or_else(|| "the name may be invalid".to_string());
|
|
format!("Gitea would not create it: {detail}")
|
|
}
|
|
ureq::Error::Status(code, _) => format!("Gitea returned HTTP {code}"),
|
|
ureq::Error::Transport(t) => {
|
|
format!("Could not reach Gitea at {api} — is the server up? ({t})")
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_api_root_is_built_from_the_server_url() {
|
|
assert_eq!(
|
|
api_base("http://192.168.1.11:4000").unwrap(),
|
|
"http://192.168.1.11:4000/api/v1"
|
|
);
|
|
// A trailing slash is what you get from copying out of a browser bar.
|
|
assert_eq!(
|
|
api_base("https://git.example.com/").unwrap(),
|
|
"https://git.example.com/api/v1"
|
|
);
|
|
// Pasting the API root itself must not double it up.
|
|
assert_eq!(
|
|
api_base("https://git.example.com/api/v1").unwrap(),
|
|
"https://git.example.com/api/v1"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unusable_server_url_says_what_is_wrong() {
|
|
assert!(api_base("").is_err());
|
|
assert!(api_base(" ").is_err());
|
|
// Without a scheme ureq cannot build a request at all, and the error it
|
|
// would raise says nothing useful.
|
|
let err = api_base("192.168.1.11:4000").unwrap_err();
|
|
assert!(err.contains("http://"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_repository_name_is_normalised_the_way_gitea_would() {
|
|
assert_eq!(normalize_name("My Book"), "My-Book");
|
|
assert_eq!(normalize_name(" spaced out "), "spaced-out");
|
|
// Runs collapse rather than piling up separators.
|
|
assert_eq!(normalize_name("a // b"), "a-b");
|
|
// Legal punctuation survives.
|
|
assert_eq!(normalize_name("the_book-2.md"), "the_book-2.md");
|
|
// Leading and trailing punctuation goes.
|
|
assert_eq!(normalize_name("--draft--"), "draft");
|
|
assert_eq!(normalize_name("!!!"), "");
|
|
}
|
|
|
|
#[test]
|
|
fn the_ssh_host_becomes_the_one_the_api_call_reached() {
|
|
// The real shape of the bug: Gitea advertises a public hostname whose
|
|
// SSH port is only open on the LAN, so the URL connects from nowhere.
|
|
assert_eq!(
|
|
rehost_ssh_url(
|
|
"ssh://git@gitlab.example.ca:2228/landon/book.git",
|
|
"100.106.219.48",
|
|
),
|
|
"ssh://git@100.106.219.48:2228/landon/book.git"
|
|
);
|
|
// The port is the server's business and is kept; so is the path.
|
|
assert_eq!(
|
|
rehost_ssh_url("ssh://git@old/deep/path/repo.git", "new"),
|
|
"ssh://git@new/deep/path/repo.git"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_scp_like_url_shape_is_rewritten_too() {
|
|
// Gitea emits this form when it is not using a custom SSH port.
|
|
assert_eq!(
|
|
rehost_ssh_url("git@gitlab.example.ca:landon/book.git", "100.106.219.48"),
|
|
"git@100.106.219.48:landon/book.git"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rehosting_leaves_a_url_alone_when_there_is_nothing_to_put_in() {
|
|
let url = "ssh://git@host:2228/landon/book.git";
|
|
assert_eq!(rehost_ssh_url(url, ""), url);
|
|
assert_eq!(rehost_ssh_url(url, " "), url);
|
|
assert_eq!(rehost_ssh_url("", "newhost"), "");
|
|
}
|
|
|
|
#[test]
|
|
fn the_host_is_pulled_out_of_the_server_url() {
|
|
assert_eq!(host_of("http://100.106.219.48:4000"), "100.106.219.48");
|
|
assert_eq!(host_of("https://git.example.com/"), "git.example.com");
|
|
assert_eq!(host_of("https://git.example.com/api/v1"), "git.example.com");
|
|
// A bare host, as someone might type it.
|
|
assert_eq!(host_of("192.168.1.11:4000"), "192.168.1.11");
|
|
// A non-numeric tail after the colon is not a port.
|
|
assert_eq!(host_of("http://user:pass@git.example.com/x"), "git.example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn the_clone_urls_come_out_of_the_reply() {
|
|
// Trimmed to the fields used; a real reply carries dozens more, which
|
|
// must not stop it deserializing.
|
|
let body = r#"{
|
|
"id": 7,
|
|
"full_name": "landon/my-book",
|
|
"ssh_url": "ssh://git@192.168.1.11:2228/landon/my-book.git",
|
|
"clone_url": "http://192.168.1.11:4000/landon/my-book.git",
|
|
"html_url": "http://192.168.1.11:4000/landon/my-book",
|
|
"empty": true,
|
|
"size": 0
|
|
}"#;
|
|
let repo = parse_repo(body, true).unwrap();
|
|
assert_eq!(repo.full_name, "landon/my-book");
|
|
assert!(repo.created);
|
|
// SSH is the default because it pushes without embedding a credential.
|
|
assert_eq!(repo.remote_url(true), "ssh://git@192.168.1.11:2228/landon/my-book.git");
|
|
assert_eq!(repo.remote_url(false), "http://192.168.1.11:4000/landon/my-book.git");
|
|
}
|
|
|
|
#[test]
|
|
fn a_reply_that_is_not_a_repository_is_reported_not_panicked() {
|
|
// What a non-Gitea server at that address would send back.
|
|
let err = parse_repo("<html><body>Not found</body></html>", true).unwrap_err();
|
|
assert!(err.contains("Unexpected reply from Gitea"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn creating_without_a_token_stops_before_the_network() {
|
|
let repo = NewRepo {
|
|
name: "book".to_string(),
|
|
description: String::new(),
|
|
private: false,
|
|
};
|
|
let err = create("http://192.168.1.11:4000", "landon", " ", &repo).unwrap_err();
|
|
assert!(err.contains("token"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn creating_without_a_name_stops_before_the_network() {
|
|
let repo = NewRepo {
|
|
name: " !! ".to_string(),
|
|
description: String::new(),
|
|
private: false,
|
|
};
|
|
let err = create("http://192.168.1.11:4000", "landon", "tok", &repo).unwrap_err();
|
|
assert!(err.contains("name"), "{err}");
|
|
}
|
|
}
|