69f12aa
use std::path::Path;
use gitbaby::command::cmd::Cmd;
use gitbaby::command::env::Env;
use gitbaby::refs::types::{ListRefsOptions, RefType};
use gitbaby::setup::types::{FastImportCommit, ObjectFormat};
use models::dir::dirs::{self, DirKind};
use models::repo::repo_collaborations;
use models::repo::repo_license;
use models::repo::repos;
use models::space::spaces;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
TransactionTrait, Unchanged,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::context::facade::GitContext;
use crate::service::GitService;
use crate::share::errors::{GitError, map_baby};
use crate::share::identity::load_identity;
use crate::share::validate::{validate_branch_name, validate_repo_name};
pub const DEFAULT_BRANCH: &str = "main";
/// 仓库页签中可由设置重排序的中间页签(代码固定最前、设置固定最后)。
pub const REORDERABLE_REPO_TABS: [&str; 5] = [
"commits",
"pulls",
"branches",
"pipelines",
"releases",
];
/// 归一化页签顺序:只保留已知中间页签、去重、限制长度;空 = 默认顺序。
pub fn normalize_repo_tab_order(order: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for id in order {
let id = id.trim();
if REORDERABLE_REPO_TABS.contains(&id) && !out.iter().any(|kept| kept == id) {
out.push(id.to_string());
}
if out.len() == REORDERABLE_REPO_TABS.len() {
break;
}
}
out
}
fn tab_order_from_json(value: Option<&serde_json::Value>) -> Vec<String> {
value
.and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
.map(|order| normalize_repo_tab_order(&order))
.unwrap_or_default()
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct RepoDto {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub website: Option<String>,
pub default_branch: String,
pub watchers_count: i32,
pub stars_count: i32,
pub pulls_count: i32,
pub commits_count: i32,
pub branches_count: i32,
pub tags_count: i32,
pub enable_lfs: bool,
pub is_private: bool,
pub is_archived: bool,
pub storage_limit_size: Option<i64>,
pub size: i64,
pub git_size: i64,
pub lfs_size: i64,
pub avatar_url: Option<String>,
pub primary_language: Option<String>,
pub license_name: Option<String>,
pub object_format: String,
pub http_clone_url: Option<String>,
pub ssh_clone_url: Option<String>,
/// 是否允许在本仓库创建合并请求(关闭后页签隐藏、接口拒绝)。
pub allow_pull_requests: bool,
/// 流水线页签是否公开展示(关闭后仅空间成员可见)。
pub pipelines_public: bool,
/// 中间页签顺序;空数组 = 默认顺序。
pub tab_order: Vec<String>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
pub last_pushed_at: Option<OffsetDateTime>,
pub archived_at: Option<OffsetDateTime>,
#[serde(default)]
pub me: Option<RepoMeDto>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct RepoMeDto {
pub starred: bool,
pub watch_mode: Option<models::common::watch_mode::WatchMode>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct RepoSummaryDto {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub default_branch: String,
pub stars_count: i32,
pub watchers_count: i32,
pub is_private: bool,
pub is_archived: bool,
pub size: i64,
pub avatar_url: Option<String>,
pub primary_language: Option<String>,
pub license_name: Option<String>,
pub updated_at: OffsetDateTime,
}
impl From<repos::Model> for RepoSummaryDto {
fn from(m: repos::Model) -> Self {
Self {
id: m.id,
name: m.name,
description: m.description,
default_branch: m
.default_branch
.strip_prefix("refs/heads/")
.unwrap_or(&m.default_branch)
.to_string(),
stars_count: m.nums_star,
watchers_count: m.nums_watch,
is_private: m.is_private,
is_archived: m.is_archived,
size: m.size,
avatar_url: m.avatar_url,
primary_language: m.primary_language,
license_name: m.license_name,
updated_at: m.updated_at,
}
}
}
impl From<repos::Model> for RepoDto {
fn from(m: repos::Model) -> Self {
Self {
id: m.id,
name: m.name,
description: m.description,
website: m.website,
default_branch: m
.default_branch
.strip_prefix("refs/heads/")
.unwrap_or(&m.default_branch)
.to_string(),
watchers_count: m.nums_watch,
stars_count: m.nums_star,
pulls_count: m.nums_pr,
commits_count: m.nums_commits,
branches_count: m.nums_branches,
tags_count: m.nums_tags,
enable_lfs: m.enable_lfs,
is_private: m.is_private,
is_archived: m.is_archived,
storage_limit_size: m.storage_limit_size,
size: m.size,
git_size: m.git_size,
lfs_size: m.lfs_size,
avatar_url: m.avatar_url,
primary_language: m.primary_language,
license_name: m.license_name,
object_format: m.object_format,
http_clone_url: None,
ssh_clone_url: None,
allow_pull_requests: m.allow_pull_requests,
pipelines_public: m.pipelines_public,
tab_order: tab_order_from_json(m.tab_order.as_ref()),
created_at: m.created_at,
updated_at: m.updated_at,
last_pushed_at: m.last_pushed_at,
archived_at: m.archived_at,
me: None,
}
}
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct CreateRepoCmd {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub is_private: bool,
#[serde(default)]
pub default_branch: Option<String>,
#[serde(default)]
pub parent_dir_id: Option<Uuid>,
#[serde(default)]
pub enable_lfs: bool,
#[serde(default)]
pub license_name: Option<String>,
#[serde(default)]
pub init_message: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
pub struct UpdateRepoCmd {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub website: Option<String>,
#[serde(default)]
pub is_private: Option<bool>,
#[serde(default)]
pub enable_lfs: Option<bool>,
#[serde(default)]
pub default_branch: Option<String>,
#[serde(default)]
pub allow_pull_requests: Option<bool>,
#[serde(default)]
pub pipelines_public: Option<bool>,
/// 中间页签顺序;传空数组恢复默认顺序。
#[serde(default)]
pub tab_order: Option<Vec<String>>,
}
/// Strip a trailing `:<port>` from a URL authority (`localhost:3000` -> `localhost`).
/// Bracketed IPv6 literals and non-numeric suffixes are left untouched.
fn strip_authority_port(authority: &str) -> &str {
match authority.rsplit_once(':') {
Some((host, port))
if !host.is_empty() && port.chars().all(|c| c.is_ascii_digit()) && !port.is_empty() =>
{
host
}
_ => authority,
}
}
impl GitService {
pub fn http_clone_url(&self, space: &str, repo: &str) -> Option<String> {
let cfg = &self.state.config.git;
let base = if cfg.http_clone_base_url.is_empty() {
cfg.base_url.as_str()
} else {
cfg.http_clone_base_url.as_str()
};
if base.is_empty() {
return None;
}
Some(format!("{}/{space}/{repo}.git", base.trim_end_matches('/')))
}
pub fn ssh_clone_url(&self, space: &str, repo: &str) -> Option<String> {
let cfg = &self.state.config.git;
let host = if cfg.ssh_clone_host.is_empty() {
// Derive from base_url; the HTTP port is not the SSH port, so drop it.
strip_authority_port(
cfg.base_url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or(""),
)
.to_string()
} else {
cfg.ssh_clone_host.clone()
};
if host.is_empty() {
return None;
}
let port = if cfg.ssh_clone_port == 0 {
cfg.ssh.port
} else {
cfg.ssh_clone_port
};
let user = if cfg.ssh_clone_user.is_empty() {
"git"
} else {
cfg.ssh_clone_user.as_str()
};
Some(if port == 22 {
format!("{user}@{host}:{space}/{repo}.git")
} else {
format!("ssh://{user}@{host}:{port}/{space}/{repo}.git")
})
}
/// 补齐 `RepoDto::from` 拿不到的派生字段:克隆地址(依赖空间名)。
///
/// 合并成一个入口,是因为仓库详情有四处出口(读取、更新、归档、取消归档),
/// 分开填充必然出现某处漏掉、界面在保存后忽然少一块信息的情况。
pub fn fill_derived(&self, dto: &mut RepoDto, space_name: &str) {
let repo_name = dto.name.clone();
dto.http_clone_url = self.http_clone_url(space_name, &repo_name);
dto.ssh_clone_url = self.ssh_clone_url(space_name, &repo_name);
}
pub async fn repo_dto(&self, m: repos::Model) -> Result<RepoDto, GitError> {
let space = crate::share::access::space_for_dir(self.db_read(), m.dir_id)
.await?
.ok_or(GitError::NotFound)?;
let mut dto = RepoDto::from(m);
self.fill_derived(&mut dto, &space.name);
Ok(dto)
}
async fn lookup_space(&self, space_name: &str) -> Result<spaces::Model, GitError> {
let lower = space_name.trim().to_lowercase();
if lower.is_empty() {
return Err(GitError::NotFound);
}
spaces::Entity::find()
.filter(spaces::Column::LowerName.eq(&lower))
.filter(spaces::Column::IsActive.eq(true))
.one(self.db_read())
.await?
.ok_or(GitError::NotFound)
}
pub async fn create_repo(
&self,
space_name: String,
caller: i64,
cmd: CreateRepoCmd,
) -> Result<RepoDto, GitError> {
let space = self.lookup_space(&space_name).await?;
if space.owner_id != caller {
return Err(GitError::Forbidden);
}
let name = validate_repo_name(&cmd.name)?;
let default_branch =
validate_branch_name(cmd.default_branch.as_deref().unwrap_or(DEFAULT_BRANCH))?;
if let Some(p) = cmd.parent_dir_id {
let parent = dirs::Entity::find_by_id(p)
.filter(dirs::Column::SpaceId.eq(space.id))
.one(self.db_read())
.await?
.ok_or(GitError::NotFound)?;
if parent.kind != DirKind::Dir {
return Err(GitError::InvalidArgument(
"parent node must be a dir".to_string(),
));
}
}
if let Some(name) = cmd.license_name.as_deref() {
let exists = repo_license::Entity::find_by_id(name.to_string())
.one(self.db_read())
.await?
.is_some();
if !exists {
return Err(GitError::InvalidArgument(format!(
"license `{name}` is not registered"
)));
}
}
let (dir, repo) = {
let txn = self.db_write().begin().await?;
let position = {
let max_node = dirs::Entity::find()
.filter(dirs::Column::SpaceId.eq(space.id))
.filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
.order_by_desc(dirs::Column::Position)
.lock(sea_orm::sea_query::LockType::Update)
.one(&txn)
.await?;
max_node.map(|n| n.position + 1).unwrap_or(0)
};
let now = OffsetDateTime::now_utc();
let dir_row = dirs::ActiveModel {
id: Set(Uuid::now_v7()),
space_id: Set(Some(space.id)),
name: Set(name.clone()),
kind: Set(DirKind::Object),
parent_dir_id: Set(cmd.parent_dir_id),
color: Set(None),
position: Set(position),
icon: Set(None),
topic: Set(None),
purpose: Set(None),
is_archived: Set(false),
archived_at: Set(None),
archived_by: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by_uid: Set(caller),
};
let dir = dir_row.insert(&txn).await?;
let repo_row = repos::ActiveModel {
id: Set(Uuid::now_v7()),
dir_id: Set(dir.id),
lower_name: Set(name.to_lowercase()),
name: Set(name),
description: Set(cmd.description.clone()),
website: Set(None),
default_branch: Set(default_branch.clone()),
nums_watch: Set(0),
nums_star: Set(0),
nums_pr: Set(0),
nums_commits: Set(0),
nums_branches: Set(0),
nums_tags: Set(0),
enable_lfs: Set(cmd.enable_lfs),
is_private: Set(cmd.is_private),
is_archived: Set(false),
storage_limit_size: Set(None),
size: Set(0),
git_size: Set(0),
lfs_size: Set(0),
avatar_url: Set(None),
primary_language: Set(None),
license_name: Set(cmd.license_name.clone()),
created_at: Set(now),
updated_at: Set(now),
last_pushed_at: Set(None),
archived_at: Set(None),
readme_raw: Set(None),
allow_rebase: Set(true),
allow_rebase_explicit: Set(false),
allow_squash_merge: Set(true),
allow_merge_commits: Set(true),
object_format: Set("sha1".to_string()),
allow_pull_requests: Set(true),
pipelines_public: Set(true),
tab_order: Set(None),
};
let repo = repo_row.insert(&txn).await?;
txn.commit().await?;
(dir, repo)
};
let cache = std::sync::Arc::new(cache::CacheClient::new(self.state.redis.clone()));
let git_cfg = &self.state.config.git;
let ctx = GitContext {
model: repo.clone(),
cache,
database: self.state.database.clone(),
repo_root: std::path::PathBuf::from(&git_cfg.repo_root),
objects_root: std::path::PathBuf::from(&git_cfg.objects_root),
};
let repo_dir = ctx.repo_dir();
let init_result: Result<(), GitError> = async {
tokio::fs::create_dir_all(&repo_dir)
.await
.map_err(|e| GitError::Internal(format!("mkdir {}: {e}", repo_dir.display())))?;
let baby = self.baby(&ctx);
baby.init_repository(&repo_dir.to_string_lossy(), true, Some(ObjectFormat::Sha1))
.await
.map_err(map_baby)?;
let identity = load_identity(&self.state.database, caller).await?;
baby.config_set("user.name", &identity.name)
.await
.map_err(map_baby)?;
baby.config_set("user.email", &identity.email)
.await
.map_err(map_baby)?;
let init_msg = cmd
.init_message
.unwrap_or_else(|| "Initial commit".to_string());
let now = OffsetDateTime::now_utc();
let commit = FastImportCommit {
ref_name: format!("refs/heads/{default_branch}"),
message: Some(init_msg),
committer_name: identity.name,
committer_email: identity.email,
committer_time: now.unix_timestamp(),
committer_tz_offset: 0,
files: Vec::new(),
};
baby.fast_import(&repo_dir, vec![commit])
.await
.map_err(map_baby)?;
Ok(())
}
.await;
if let Err(err) = init_result {
if let Err(e) = tokio::fs::remove_dir_all(&repo_dir).await {
tracing::warn!(error = %e, "failed to clean up repo dir after init failure");
}
if let Err(e) = repos::Entity::delete_by_id(repo.id)
.exec(self.db_write())
.await
{
tracing::warn!(error = %e, "failed to clean up repo row after init failure");
}
if let Err(e) = dirs::Entity::delete_by_id(dir.id)
.exec(self.db_write())
.await
{
tracing::warn!(error = %e, "failed to clean up dir row after init failure");
}
return Err(err);
}
self.sync_repo_stats(&ctx).await?;
let fresh = repos::Entity::find_by_id(repo.id)
.one(self.db_read())
.await?
.ok_or(GitError::NotFound)?;
let mut dto = RepoDto::from(fresh);
self.fill_derived(&mut dto, &space.name);
Ok(dto)
}
pub async fn get_repo(
&self,
space_name: String,
repo_name: String,
) -> Result<RepoDto, GitError> {
let ctx = self.context(space_name, repo_name).await?;
self.repo_dto(ctx.model).await
}
pub async fn repo_viewer_state(&self, repo_id: Uuid, uid: i64) -> Result<RepoMeDto, GitError> {
use models::repo::repo_star;
use models::repo::repo_watch;
let starred = repo_star::Entity::find()
.filter(repo_star::Column::RepoId.eq(repo_id))
.filter(repo_star::Column::UserId.eq(uid))
.one(self.db_read())
.await?
.is_some();
let watch_mode = repo_watch::Entity::find()
.filter(repo_watch::Column::RepoId.eq(repo_id))
.filter(repo_watch::Column::UserId.eq(uid))
.one(self.db_read())
.await?
.map(|w| w.mode);
Ok(RepoMeDto {
starred,
watch_mode,
})
}
pub async fn get_repo_for_html(
&self,
ctx: std::sync::Arc<crate::context::facade::GitContext>,
) -> Result<RepoDto, GitError> {
self.repo_dto(ctx.model.clone()).await
}
pub async fn list_repos(
&self,
space_name: String,
caller: Option<i64>,
page: export::pagination::PageQuery,
) -> Result<export::pagination::Page<RepoSummaryDto>, GitError> {
let space = self.lookup_space(&space_name).await?;
let dirs = dirs::Entity::find()
.filter(dirs::Column::SpaceId.eq(space.id))
.filter(dirs::Column::Kind.eq(DirKind::Object))
.all(self.db_read())
.await?;
let dir_ids: Vec<Uuid> = dirs.iter().map(|d| d.id).collect();
let mut sel = repos::Entity::find().filter(repos::Column::DirId.is_in(dir_ids));
sel = match page.sort.as_deref() {
Some("created_at") => sel.order_by_asc(repos::Column::CreatedAt),
Some("-updated_at") => sel.order_by_desc(repos::Column::UpdatedAt),
Some("updated_at") => sel.order_by_asc(repos::Column::UpdatedAt),
Some("stars") => sel.order_by_desc(repos::Column::NumsStar),
Some("name") => sel.order_by_asc(repos::Column::Name),
_ => sel.order_by_asc(repos::Column::CreatedAt),
};
let rows = sel.all(self.db_read()).await?;
let owner_view = caller.is_some_and(|uid| space.owner_id == uid);
let mut accessible: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
if let Some(uid) = caller
&& !owner_view
{
let repo_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let collabs = repo_collaborations::Entity::find()
.filter(repo_collaborations::Column::UserId.eq(uid))
.filter(repo_collaborations::Column::RepoId.is_in(repo_ids))
.all(self.db_read())
.await?;
accessible.extend(collabs.into_iter().map(|c| c.repo_id));
}
let visible: Vec<RepoSummaryDto> = rows
.into_iter()
.filter(|r| !r.is_private || owner_view || accessible.contains(&r.id))
.map(RepoSummaryDto::from)
.collect();
let total = visible.len() as i64;
let (limit, offset) = page.page();
let items = visible
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok(export::pagination::Page {
items,
total,
limit,
offset,
})
}
pub async fn update_repo(
&self,
space_name: String,
repo_name: String,
caller: i64,
cmd: UpdateRepoCmd,
) -> Result<RepoDto, GitError> {
let ctx = self.context(space_name, repo_name).await?;
let space = self.repo_space(&ctx).await?;
if space.owner_id != caller {
return Err(GitError::Forbidden);
}
let new_branch = match &cmd.default_branch {
Some(b) => Some(validate_branch_name(b)?),
None => None,
};
let old_branch = ctx.model.default_branch.clone();
let now = OffsetDateTime::now_utc();
let mut am = repos::ActiveModel {
id: Unchanged(ctx.model.id),
..Default::default()
};
if let Some(desc) = cmd.description {
am.description = Set(Some(desc));
}
if let Some(website) = cmd.website {
am.website = Set(Some(website));
}
if let Some(private) = cmd.is_private {
am.is_private = Set(private);
}
if let Some(lfs) = cmd.enable_lfs {
am.enable_lfs = Set(lfs);
}
if let Some(branch) = &new_branch {
am.default_branch = Set(branch.clone());
}
// 页签公开性与合并请求开关互相独立:只传其中一个时不动其余。
if let Some(value) = cmd.allow_pull_requests {
am.allow_pull_requests = Set(value);
}
if let Some(value) = cmd.pipelines_public {
am.pipelines_public = Set(value);
}
if let Some(order) = &cmd.tab_order {
am.tab_order = Set(Some(serde_json::json!(normalize_repo_tab_order(order))));
}
am.updated_at = Set(now);
let saved = am.update(self.db_write()).await?;
if let Some(branch) = new_branch
&& branch != old_branch
{
let baby = self.baby(&ctx);
if let Err(e) = baby.set_default_branch(&branch).await {
let revert = repos::ActiveModel {
id: Unchanged(ctx.model.id),
default_branch: Set(old_branch),
updated_at: Set(now),
..Default::default()
};
let _ = revert.update(self.db_write()).await;
return Err(map_baby(e));
}
}
let mut dto = RepoDto::from(saved);
self.fill_derived(&mut dto, &space.name);
Ok(dto)
}
pub async fn set_repo_archived(
&self,
space_name: String,
repo_name: String,
caller: i64,
archived: bool,
) -> Result<RepoDto, GitError> {
let ctx = self.context(space_name, repo_name).await?;
let space = self.repo_space(&ctx).await?;
if space.owner_id != caller {
return Err(GitError::Forbidden);
}
if ctx.model.is_archived == archived {
return Err(GitError::Conflict(if archived {
"repo is already archived".to_string()
} else {
"repo is not archived".to_string()
}));
}
let now = OffsetDateTime::now_utc();
let am = repos::ActiveModel {
id: Unchanged(ctx.model.id),
is_archived: Set(archived),
archived_at: Set(archived.then_some(now)),
updated_at: Set(now),
..Default::default()
};
let saved = am.update(self.db_write()).await?;
let mut dto = RepoDto::from(saved);
self.fill_derived(&mut dto, &space.name);
Ok(dto)
}
async fn repo_space(&self, ctx: &GitContext) -> Result<spaces::Model, GitError> {
crate::share::access::space_for_dir(self.db_read(), ctx.model.dir_id)
.await?
.ok_or(GitError::NotFound)
}
pub async fn sync_repo_stats(&self, ctx: &GitContext) -> Result<(), GitError> {
let baby = self.baby(ctx);
let refs = baby
.list_refs(ListRefsOptions::default())
.await
.map_err(map_baby)?;
let branches_count = refs
.iter()
.filter(|r| r.ref_type == RefType::Branch)
.count() as i32;
let tags_count = refs.iter().filter(|r| r.ref_type == RefType::Tag).count() as i32;
let commits_count = count_commits(
&ctx.repo_dir(),
self.state.config.git.git_command_timeout_secs,
)
.await? as i32;
let git_size = dir_size(&ctx.repo_dir()).await? as i64;
let now = OffsetDateTime::now_utc();
let am = repos::ActiveModel {
id: Unchanged(ctx.model.id),
nums_branches: Set(branches_count),
nums_tags: Set(tags_count),
nums_commits: Set(commits_count),
git_size: Set(git_size),
updated_at: Set(now),
..Default::default()
};
am.update(self.db_write()).await?;
Ok(())
}
}
pub(crate) async fn count_commits(dir: &Path, timeout: u64) -> Result<i64, GitError> {
let mut cmd = Cmd::new(
"gitdata::git:count_commits",
"git",
vec![
"rev-list".to_string(),
"--count".to_string(),
"--all".to_string(),
],
Vec::new(),
dir.to_path_buf(),
Env::new(),
Some(timeout),
);
let output = cmd
.run()
.await
.map_err(|e| GitError::Internal(format!("rev-list failed: {e}")))?;
if !output.status.success() {
return Err(GitError::Internal(format!(
"rev-list failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let text = String::from_utf8_lossy(&output.stdout);
text.trim()
.parse::<i64>()
.map_err(|e| GitError::Internal(format!("parse commit count `{text}`: {e}")))
}
pub(crate) async fn dir_size(path: &Path) -> std::io::Result<u64> {
let mut total = 0u64;
let mut entries = tokio::fs::read_dir(path).await?;
while let Some(entry) = entries.next_entry().await? {
let meta = entry.metadata().await?;
if meta.is_dir() {
total += Box::pin(dir_size(&entry.path())).await?;
} else {
total += meta.len();
}
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::strip_authority_port;
#[test]
fn strips_http_port_but_keeps_host() {
assert_eq!(strip_authority_port("localhost:3000"), "localhost");
assert_eq!(
strip_authority_port("git.example.com:8443"),
"git.example.com"
);
assert_eq!(strip_authority_port("[::1]:3000"), "[::1]");
}
#[test]
fn leaves_host_without_numeric_port_intact() {
assert_eq!(strip_authority_port("localhost"), "localhost");
assert_eq!(strip_authority_port("localhost:"), "localhost:");
assert_eq!(strip_authority_port("[::1]"), "[::1]");
assert_eq!(strip_authority_port(""), "");
}
}