Skip to content

Commit

Permalink
feat: find upstream remote when using ssh
Browse files Browse the repository at this point in the history
The `upstream_remote` function was relying on `url::Url::parse` to extract the `owner` and `repo` from the `url`. But that only works when the repo is cloned using a URL, e.g. `https://github.com/orhun/git-cliff.git`. However, this would fail to parse when cloned using SSH, e.g. `git@github.com:orhun/git-cliff.git`.

If the url::URL::parser fails, we now try to parse an SSH remote in the format `git@hostname:owner/repo.git`.

The error from `upstream_remote` also notes that a posible reason for it failing would be that the `HEAD` is detached.
  • Loading branch information
kemitix authored and Paul Campbell committed Oct 21, 2024
1 parent 82b10ac commit ed3d336
Show file tree
Hide file tree
Showing 2 changed files with 109 additions and 19 deletions.
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Note that we have a [Code of Conduct](./CODE_OF_CONDUCT.md), please follow it in

```sh
git clone https://github.com/{username}/git-cliff && cd git-cliff
# OR
git clone git@github.com:{username}/git-cliff && cd git-cliff
```

To ensure the successful execution of the tests, it is essential to fetch the tags as follows:
Expand Down
126 changes: 107 additions & 19 deletions git-cliff-core/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,8 @@ impl Repository {
///
/// Find the branch that HEAD points to, and read the remote configured for
/// that branch returns the remote and the name of the local branch.
///
/// Note: HEAD must not be detached.
pub fn upstream_remote(&self) -> Result<Remote> {
for branch in self.inner.branches(Some(BranchType::Local))? {
let branch = branch?.0;
Expand All @@ -424,30 +426,98 @@ impl Repository {
})?
.to_string();
trace!("Upstream URL: {url}");
let url = Url::parse(&url)?;
let segments: Vec<&str> = url
.path_segments()
.ok_or_else(|| {
Error::RepoError(String::from("failed to get URL segments"))
})?
.rev()
.collect();
if let (Some(owner), Some(repo)) =
(segments.get(1), segments.first())
{
return Ok(Remote {
owner: owner.to_string(),
repo: repo.trim_end_matches(".git").to_string(),
token: None,
is_custom: false,
});
}
return find_remote(&url);
}
}
Err(Error::RepoError(String::from("no remotes configured")))
Err(Error::RepoError(String::from(
"no remotes configured or HEAD is detached",
)))
}
}

fn find_remote(url: &str) -> Result<Remote> {
url_path_segments(url).or_else(|err| {
if url.contains("@") && url.contains(":") && url.contains("/") {
ssh_path_segments(url)
} else {
Err(err)
}
})
}

/// Returns the Remote from parsing the HTTPS format URL.
///
/// This function expects the URL to be in the following format:
///
/// https://hostname/query/path.git
///
/// The key part is the query path, where only the last two path segments are
/// used as the owner and repo in that order.
///
/// The returned `Remote` will contain `owner` and `repo` taken from the query
/// path.
///
/// This function will return an `Error::UrlParseError` if the URL is malformed.
///
/// This function will return an `Error::RepoError` if the query path has less
/// than two path segments.
fn url_path_segments(url: &str) -> Result<Remote> {
let parsed_url = Url::parse(url.strip_suffix(".git").unwrap_or(url))?;
let segments: Vec<&str> = parsed_url
.path_segments()
.ok_or_else(|| Error::RepoError(String::from("failed to get URL segments")))?
.rev()
.collect();
let [repo, owner, ..] = &segments[..] else {
return Err(Error::RepoError(String::from(
"failed to get the owner and repo",
)));
};
Ok(Remote {
owner: owner.to_string(),
repo: repo.to_string(),
token: None,
is_custom: false,
})
}

/// Returns the Remote from parsing the SSH format URL.
///
/// This function expects the URL to be in the following format:
///
/// git@hostname:owner/repo.git
///
/// The key parts are the colon (:) and the path separator (/).
///
/// The returned `Remote` will contain the `owner` and `repo` parts from the
/// URL.
///
/// This function will return an `Error::RepoError` if the colon or separator
/// are not found.
fn ssh_path_segments(url: &str) -> Result<Remote> {
let [_, owner_repo, ..] = url
.strip_suffix(".git")
.unwrap_or(url)
.split(":")
.collect::<Vec<_>>()[..]
else {
return Err(Error::RepoError(String::from(
"failed to get the owner and repo from ssh remote (:)",
)));
};
let [owner, repo] = owner_repo.split("/").collect::<Vec<_>>()[..] else {
return Err(Error::RepoError(String::from(
"failed to get the owner and repo from ssh remote (/)",
)));
};
Ok(Remote {
owner: owner.to_string(),
repo: repo.to_string(),
token: None,
is_custom: false,
})
}

#[cfg(test)]
mod test {
use super::*;
Expand Down Expand Up @@ -502,6 +572,24 @@ mod test {
)
}

#[test]
fn http_url_repo_owner() -> Result<()> {
let url = "https://hostname.com/bob/magic.git";
let remote = find_remote(url)?;
assert_eq!(remote.owner, "bob", "match owner");
assert_eq!(remote.repo, "magic", "match repo");
Ok(())
}

#[test]
fn ssh_url_repo_owner() -> Result<()> {
let url = "git@hostname.com:bob/magic.git";
let remote = find_remote(url)?;
assert_eq!(remote.owner, "bob", "match owner");
assert_eq!(remote.repo, "magic", "match repo");
Ok(())
}

#[test]
fn get_latest_commit() -> Result<()> {
let repository = get_repository()?;
Expand Down

0 comments on commit ed3d336

Please sign in to comment.