Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/bootstrap/repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ from that checkout.
for that path.
- **Safe updates only** — mise clones missing repos or empty target
directories and updates existing repos only when the worktree is clean and
the configured `origin` URL matches.
the configured `origin` URL matches. Exactly three network URL forms are
compared transport-agnostically: `git@host:path`, `ssh://git@host/path`,
and `https://host/path` identify the same repo. Different hosts, ssh
aliases, explicit ports, paths, or non-`git` ssh users still conflict.
Everything else requires an exact match: `http://` and `git://` origins
(an insecure transport is never silently treated as the https config),
ssh origins without a user (git resolves those to the login user, not
`git`), URLs carrying a query string, local paths, and `file://` URLs.
- **No implicit writes** — repos are changed only by explicit `apply`, `update`,
`exec`, or top-level `mise bootstrap` commands. Applying never pulls an
existing repo without a configured `ref`; use `mise bootstrap repos update`
Expand Down
206 changes: 204 additions & 2 deletions src/system/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
//!
//! Repos are applied only during explicit bootstrap commands. Existing repos
//! are updated only when the worktree is clean and the configured origin
//! matches.
//! matches. Origins are compared transport-agnostically for common network
//! forms, so an ssh origin matches its https equivalent.

use std::path::{Path, PathBuf};
use std::process::Command;

use eyre::{Result, bail, eyre};
use serde::Deserialize;
use url::Url;

use crate::file;

Expand Down Expand Up @@ -422,7 +424,77 @@ fn origin_matches_config(origin: Option<&str>, config_url: &str) -> bool {
let Some(origin) = origin else {
return false;
};
origin == config_url || normalize_remote_url(origin) == normalize_remote_url(config_url)
if origin == config_url || normalize_remote_url(origin) == normalize_remote_url(config_url) {
return true;
}
match (repo_identity(origin), repo_identity(config_url)) {
(Some(origin), Some(config)) => origin == config,
_ => false,
}
}

/// Reduces a network remote URL to a host/path identity so the same repo is
/// recognized across transports — exactly the forms agreed in #10997:
/// `git@host:path`, `ssh://git@host/path`, and `https://host/path` compare
/// equal. Everything else keeps exact matching: `http://` and `git://` stay
/// distinct so an insecure transport is never silently preserved as
/// equivalent to a configured https url; ssh forms without a user are not
/// normalized (git resolves an omitted ssh user to the current login user,
/// not `git`); URLs carrying a query or fragment, local paths, `file://`
/// URLs, and unrecognized syntax return `None`. Explicit ports and non-`git`
/// users stay part of the identity since they can address different
/// repositories.
fn repo_identity(url: &str) -> Option<String> {
let url = normalize_remote_url(url);
if let Some((scheme, _)) = url.split_once("://") {
let scheme = scheme.to_ascii_lowercase();
if !matches!(scheme.as_str(), "ssh" | "https") {
return None;
}
let url = Url::parse(&url).ok()?;
if url.query().is_some() || url.fragment().is_some() {
return None;
}
// https userinfo is credential material, not repository identity — it
// must not occupy the same identity slot as an ssh user. ssh needs an
// explicit user (an omitted one means the login user, not `git`).
match scheme.as_str() {
"https" if !url.username().is_empty() || url.password().is_some() => return None,
"ssh" if url.username().is_empty() => return None,
_ => {}
}
let host = url.host_str()?.to_string();
repo_identity_parts(url.username(), &host, url.port(), url.path())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
jeremy marked this conversation as resolved.
Resolved
} else if is_scp_like_url(&url) {
let (authority, path) = url.split_once(':')?;
if authority.contains(['[', ']']) {
return None;
}
// scp-like syntax is always ssh: no user means the login user, so
// only explicit-user forms participate in identity comparison.
let (user, host) = authority.rsplit_once('@')?;
if user.is_empty() {
return None;
}
repo_identity_parts(user, host, None, path)
} else {
None
}
}

fn repo_identity_parts(user: &str, host: &str, port: Option<u16>, path: &str) -> Option<String> {
let path = path.trim_matches('/');
Comment thread
jeremy marked this conversation as resolved.
Resolved
if host.is_empty() || path.is_empty() {
return None;
}
// the conventional `git` ssh user is transport metadata on forges
let user = match user {
"" | "git" => String::new(),
Comment thread
jeremy marked this conversation as resolved.
Resolved
user => format!("{user}@"),
};
let host = host.to_ascii_lowercase();
let port = port.map(|port| format!(":{port}")).unwrap_or_default();
Some(format!("{user}{host}{port}/{path}"))
}

fn normalize_remote_url(url: &str) -> String {
Expand Down Expand Up @@ -813,6 +885,136 @@ mod tests {
assert!(!origin_matches_config(None, "https://github.com/jdx/mise"));
}

#[test]
fn origin_urls_match_the_same_repo_across_transports() {
let https = "https://github.com/jdx/mise.git";
for origin in [
"git@github.com:jdx/mise.git",
"git@github.com:jdx/mise",
"ssh://git@github.com/jdx/mise.git",
"ssh://git@github.com/jdx/mise",
"https://GitHub.com/jdx/mise.git",
] {
assert!(
origin_matches_config(Some(origin), https),
"{origin} should match {https}"
);
assert!(
origin_matches_config(Some(https), origin),
"{https} should match {origin}"
);
}
// the same non-git user on both sides is the same identity
assert!(origin_matches_config(
Some("alice@example.com:team/repo.git"),
"ssh://alice@example.com/team/repo"
));
// scp paths compare without their optional leading slash
assert!(origin_matches_config(
Some("git@example.com:/srv/git/repo.git"),
"ssh://git@example.com/srv/git/repo"
));
// explicit ports match when they agree
assert!(origin_matches_config(
Some("ssh://git@example.com:2222/team/repo.git"),
"ssh://git@example.com:2222/team/repo"
));
}

#[test]
fn origin_urls_conflict_across_hosts_users_ports_and_paths() {
let https = "https://github.com/jdx/mise.git";
for origin in [
"git@gitlab.com:jdx/mise.git", // different host
"git@github-work:jdx/mise.git", // ssh alias
"ssh://git@github.com:2222/jdx/mise.git", // explicit port
"git@github.com:jdx/other.git", // different repo
"git@github.com:other/mise.git", // different owner
"alice@github.com:jdx/mise.git", // non-git ssh user
"ssh://alice@github.com/jdx/mise.git", // non-git ssh user (url form)
"ssh://github.com/jdx/mise.git", // userless ssh = login user, not git
"github.com:jdx/mise.git", // userless scp = login user, not git
"https://github.com/jdx/mise?tenant=a", // query strings are not identity
"https://github.com/jdx/mise.git", // insecure transport is not equivalent
"git://github.com/jdx/mise.git", // insecure transport is not equivalent
"https://alice@github.com/jdx/mise.git", // https userinfo is credentials, not identity
"ftp://github.com/jdx/mise.git", // unrecognized scheme
] {
assert!(
!origin_matches_config(Some(origin), https),
"{origin} should not match {https}"
);
}
assert!(!origin_matches_config(
Some("ssh://git@github.com:2222/jdx/mise.git"),
"ssh://git@github.com/jdx/mise.git"
));
assert!(!origin_matches_config(
Some("alice@github.com:jdx/mise.git"),
"git@github.com:jdx/mise.git"
));
// userless ssh and query-carrying URLs only match themselves exactly
assert!(origin_matches_config(
Some("ssh://github.com/jdx/mise.git"),
"ssh://github.com/jdx/mise.git"
));
assert!(!origin_matches_config(
Some("ssh://github.com/jdx/mise.git"),
"git@github.com:jdx/mise.git"
));
assert!(!origin_matches_config(
Some("https://github.com/jdx/mise?tenant=a"),
"https://github.com/jdx/mise?tenant=b"
));
// https userinfo never occupies the ssh-user identity slot
assert!(!origin_matches_config(
Some("ssh://alice@example.com/team/repo.git"),
"https://alice@example.com/team/repo.git"
));
// local and file:// forms keep exact matching
assert!(!origin_matches_config(
Some("/srv/git/mise.git"),
"file:///srv/git/mise.git"
));
assert!(!origin_matches_config(
Some(r"C:\repos\mise"),
"https://github.com/jdx/mise"
));
}

#[test]
fn status_recognizes_origin_across_transports() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
init_repo(&repo);
test_git(
&repo,
&["remote", "add", "origin", "git@github.com:jdx/mise.git"],
);

let request = RepoRequest {
path_raw: repo.display().to_string(),
path: repo.clone(),
url: "https://github.com/jdx/mise.git".to_string(),
git_ref: None,
};
assert_eq!(status_one(&request).unwrap().state, RepoState::Current);

test_git(
&repo,
&[
"remote",
"set-url",
"origin",
"git@github.com:other/mise.git",
],
);
assert_eq!(
status_one(&request).unwrap().state,
RepoState::Conflict("origin does not match configured url".to_string())
);
}

#[test]
fn preflight_statuses_blocks_dirty_repos_before_mutation() {
let tmp = tempfile::tempdir().unwrap();
Expand Down