Compare commits
4 Commits
c4391155cd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 981cd853b9 | |||
| e77d29047c | |||
| d6ccf12f51 | |||
| c42f11b86c |
@@ -84,6 +84,16 @@ Terminal=false
|
||||
**Attach over SSH** to use the HTTPS clone URL instead, which asks for a
|
||||
credential on every push.
|
||||
|
||||
The SSH host comes from the **server URL you configured**, not from the URL
|
||||
Gitea advertises. Gitea builds that from its own `SSH_DOMAIN`, which is
|
||||
whatever it was told to announce — and a server announcing a public hostname
|
||||
whose SSH port is only reachable on the LAN hands out a remote that connects
|
||||
from nowhere, with the failure surfacing at the first push rather than at
|
||||
creation. The server URL is known to work, because the API call just went
|
||||
over it. The port Gitea reports is kept as-is. So on a tailnet, setting the
|
||||
Gitea server to `http://100.x.y.z:4000` gets you
|
||||
`ssh://git@100.x.y.z:2228/…`, which works on and off the LAN alike.
|
||||
|
||||
After that, the **⟳ Sync (git)** button commits all changes, `pull --rebase`s,
|
||||
and pushes (setting the upstream on the first push). Until origin is set it
|
||||
commits locally and says so in the log.
|
||||
|
||||
@@ -1226,8 +1226,11 @@ impl App {
|
||||
save_now |= ui
|
||||
.checkbox(&mut self.config.gitea_use_ssh, "Attach over SSH")
|
||||
.on_hover_text(
|
||||
"SSH pushes on a key. Turn this off to use the HTTPS clone \
|
||||
URL, which asks for a credential on every push.",
|
||||
"SSH pushes on a key. The host is taken from the server URL \
|
||||
above rather than from what Gitea advertises, so the remote \
|
||||
points at an address this machine can actually reach. Turn \
|
||||
this off to use the HTTPS clone URL, which asks for a \
|
||||
credential on every push.",
|
||||
)
|
||||
.changed();
|
||||
ui.add_space(4.0);
|
||||
|
||||
+128
-2
@@ -124,6 +124,80 @@ pub fn normalize_name(name: &str) -> String {
|
||||
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
|
||||
@@ -167,7 +241,7 @@ pub fn create(server: &str, user: &str, token: &str, repo: &NewRepo) -> Result<R
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&body.to_string());
|
||||
|
||||
match result {
|
||||
let repo = match result {
|
||||
Ok(resp) => {
|
||||
let text = resp
|
||||
.into_string()
|
||||
@@ -177,7 +251,13 @@ pub fn create(server: &str, user: &str, token: &str, repo: &NewRepo) -> Result<R
|
||||
// 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.
|
||||
@@ -294,6 +374,52 @@ mod tests {
|
||||
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
|
||||
|
||||
+50
-15
@@ -64,6 +64,26 @@ fn has_origin(workspace: &Path) -> bool {
|
||||
ok && out.lines().any(|l| l.trim() == "origin")
|
||||
}
|
||||
|
||||
/// True if the current branch has an upstream to pull from.
|
||||
///
|
||||
/// A repository being pushed for the first time has none, and `rev-parse`
|
||||
/// exits non-zero rather than printing anything.
|
||||
fn has_upstream(workspace: &Path) -> bool {
|
||||
run_git(workspace, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).0
|
||||
}
|
||||
|
||||
/// The push for a branch, which has to set the upstream the first time.
|
||||
///
|
||||
/// Kept separate from running it so it can be asserted in tests, like
|
||||
/// [`origin_args`].
|
||||
pub fn push_args(branch: &str, has_upstream: bool) -> Vec<&str> {
|
||||
if has_upstream {
|
||||
vec!["push"]
|
||||
} else {
|
||||
vec!["push", "--set-upstream", "origin", branch]
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL `origin` points at, if the remote exists.
|
||||
///
|
||||
/// Read from the repository rather than from this app's settings: a remote
|
||||
@@ -140,23 +160,22 @@ pub fn sync(workspace: &Path, message: &str) -> GitOutcome {
|
||||
}
|
||||
|
||||
if has_origin(workspace) {
|
||||
let (p_ok, p_out) = run_git(workspace, &["pull", "--rebase", "--autostash"]);
|
||||
log.push_str(&p_out);
|
||||
ok &= p_ok;
|
||||
|
||||
let (push_ok, push_out) = run_git(workspace, &["push"]);
|
||||
log.push_str(&push_out);
|
||||
// A failed push often just means no upstream is set yet; surface it but
|
||||
// do not treat a missing upstream as a hard failure of the whole sync.
|
||||
if !push_ok && push_out.contains("no upstream") {
|
||||
let branch = current_branch(workspace);
|
||||
let (u_ok, u_out) =
|
||||
run_git(workspace, &["push", "--set-upstream", "origin", &branch]);
|
||||
log.push_str(&u_out);
|
||||
ok &= u_ok;
|
||||
let upstream = has_upstream(workspace);
|
||||
if upstream {
|
||||
let (p_ok, p_out) = run_git(workspace, &["pull", "--rebase", "--autostash"]);
|
||||
log.push_str(&p_out);
|
||||
ok &= p_ok;
|
||||
} else {
|
||||
ok &= push_ok;
|
||||
// Nothing to rebase onto yet, and pulling anyway fails with "no
|
||||
// tracking information" — which used to make a first sync report
|
||||
// errors even though its push had gone through fine.
|
||||
log.push_str("(no upstream branch yet — nothing to pull)\n");
|
||||
}
|
||||
|
||||
let branch = current_branch(workspace);
|
||||
let (push_ok, push_out) = run_git(workspace, &push_args(&branch, upstream));
|
||||
log.push_str(&push_out);
|
||||
ok &= push_ok;
|
||||
} else {
|
||||
log.push_str("(no 'origin' remote configured — committed locally only)\n");
|
||||
}
|
||||
@@ -208,6 +227,22 @@ fn strip_command_echo(text: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_first_push_sets_the_upstream_and_later_ones_do_not() {
|
||||
// Without this the first push fails with "has no upstream branch".
|
||||
assert_eq!(
|
||||
push_args("main", false),
|
||||
vec!["push", "--set-upstream", "origin", "main"]
|
||||
);
|
||||
// Once it is set, a plain push is what respects the tracking config.
|
||||
assert_eq!(push_args("main", true), vec!["push"]);
|
||||
// Whatever the branch is actually called.
|
||||
assert_eq!(
|
||||
push_args("draft-2", false),
|
||||
vec!["push", "--set-upstream", "origin", "draft-2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_a_remote_adds_it_only_when_it_is_missing() {
|
||||
// Fresh repository: the remote has to be created.
|
||||
|
||||
Reference in New Issue
Block a user