Compare commits
2 Commits
c4391155cd
...
d6ccf12f51
| Author | SHA1 | Date | |
|---|---|---|---|
| d6ccf12f51 | |||
| c42f11b86c |
@@ -84,6 +84,16 @@ Terminal=false
|
|||||||
**Attach over SSH** to use the HTTPS clone URL instead, which asks for a
|
**Attach over SSH** to use the HTTPS clone URL instead, which asks for a
|
||||||
credential on every push.
|
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,
|
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
|
and pushes (setting the upstream on the first push). Until origin is set it
|
||||||
commits locally and says so in the log.
|
commits locally and says so in the log.
|
||||||
|
|||||||
@@ -1226,8 +1226,11 @@ impl App {
|
|||||||
save_now |= ui
|
save_now |= ui
|
||||||
.checkbox(&mut self.config.gitea_use_ssh, "Attach over SSH")
|
.checkbox(&mut self.config.gitea_use_ssh, "Attach over SSH")
|
||||||
.on_hover_text(
|
.on_hover_text(
|
||||||
"SSH pushes on a key. Turn this off to use the HTTPS clone \
|
"SSH pushes on a key. The host is taken from the server URL \
|
||||||
URL, which asks for a credential on every push.",
|
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();
|
.changed();
|
||||||
ui.add_space(4.0);
|
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()
|
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.
|
/// 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
|
/// 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")
|
.set("Content-Type", "application/json")
|
||||||
.send_string(&body.to_string());
|
.send_string(&body.to_string());
|
||||||
|
|
||||||
match result {
|
let repo = match result {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let text = resp
|
let text = resp
|
||||||
.into_string()
|
.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.
|
// Already there: attach to it rather than making the user rename.
|
||||||
Err(ureq::Error::Status(409, _)) => fetch(&agent, &api, token, user, &name),
|
Err(ureq::Error::Status(409, _)) => fetch(&agent, &api, token, user, &name),
|
||||||
Err(e) => Err(friendly_error(&api, e)),
|
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.
|
/// Read back a repository that already exists.
|
||||||
@@ -294,6 +374,52 @@ mod tests {
|
|||||||
assert_eq!(normalize_name("!!!"), "");
|
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]
|
#[test]
|
||||||
fn the_clone_urls_come_out_of_the_reply() {
|
fn the_clone_urls_come_out_of_the_reply() {
|
||||||
// Trimmed to the fields used; a real reply carries dozens more, which
|
// Trimmed to the fields used; a real reply carries dozens more, which
|
||||||
|
|||||||
+47
-12
@@ -64,6 +64,26 @@ fn has_origin(workspace: &Path) -> bool {
|
|||||||
ok && out.lines().any(|l| l.trim() == "origin")
|
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.
|
/// The URL `origin` points at, if the remote exists.
|
||||||
///
|
///
|
||||||
/// Read from the repository rather than from this app's settings: a remote
|
/// 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) {
|
if has_origin(workspace) {
|
||||||
|
let upstream = has_upstream(workspace);
|
||||||
|
if upstream {
|
||||||
let (p_ok, p_out) = run_git(workspace, &["pull", "--rebase", "--autostash"]);
|
let (p_ok, p_out) = run_git(workspace, &["pull", "--rebase", "--autostash"]);
|
||||||
log.push_str(&p_out);
|
log.push_str(&p_out);
|
||||||
ok &= p_ok;
|
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;
|
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
log.push_str("(no 'origin' remote configured — committed locally only)\n");
|
log.push_str("(no 'origin' remote configured — committed locally only)\n");
|
||||||
}
|
}
|
||||||
@@ -208,6 +227,22 @@ fn strip_command_echo(text: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn setting_a_remote_adds_it_only_when_it_is_missing() {
|
fn setting_a_remote_adds_it_only_when_it_is_missing() {
|
||||||
// Fresh repository: the remote has to be created.
|
// Fresh repository: the remote has to be created.
|
||||||
|
|||||||
Reference in New Issue
Block a user