Compare commits

...

2 Commits

Author SHA1 Message Date
landon d6ccf12f51 Don't report a first sync as failed
A repository being pushed for the first time has no upstream, so the
pull leg failed with "There is no tracking information for the current
branch" and dragged the whole sync's status down — the app said "Sync
finished with errors" even though the push had gone through and set the
upstream itself.

The upstream is now checked up front. Without one there is nothing to
rebase onto, so the pull is skipped with a note in the log and the push
sets the upstream directly, rather than being run once to fail and then
retried. The argument choice moves to push_args so it can be asserted
without a repository on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
2026-09-09 19:41:21 -05:00
landon c42f11b86c Point a created repo's SSH URL at a host we can reach
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
2026-09-09 19:36:15 -05:00
4 changed files with 193 additions and 19 deletions
+10
View File
@@ -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.
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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.