use std::collections::HashMap;
use std::path::Path;
use std::time::SystemTime;
use serde::Serialize;
use super::checkout;
use super::command;
use super::constants::GIT_ABBREV_REF_ARG;
use super::constants::GIT_CONFIG_COMMAND;
use super::constants::GIT_CONFIG_REMOTE_PREFIX;
use super::constants::GIT_CONFIG_REMOTE_PUSHURL_PATTERN;
use super::constants::GIT_CONFIG_REMOTE_PUSHURL_SUFFIX;
use super::constants::GIT_FORMAT_ISO8601_ARG;
use super::constants::GIT_GET_REGEXP_ARG;
use super::constants::GIT_GET_URL_ARG;
use super::constants::GIT_HEAD;
use super::constants::GIT_HEAD_REVSPEC_PREFIX;
use super::constants::GIT_LOCAL_BRANCH_REF_PREFIX;
use super::constants::GIT_LOG_COMMAND;
use super::constants::GIT_MAX_PARENTS_ZERO_ARG;
use super::constants::GIT_ORIGIN_HEAD_REF;
use super::constants::GIT_QUIET_ARG;
use super::constants::GIT_REMOTE_COMMAND;
use super::constants::GIT_REMOTE_HEAD_REF_SUFFIX;
use super::constants::GIT_REMOTE_ORIGIN;
use super::constants::GIT_REMOTE_ORIGIN_PREFIX;
use super::constants::GIT_REMOTE_REF_PREFIX;
use super::constants::GIT_REMOTE_UPSTREAM;
use super::constants::GIT_REV_PARSE_COMMAND;
use super::constants::GIT_REVERSE_ARG;
use super::constants::GIT_SHORT_ARG;
use super::constants::GIT_SHOW_REF_COMMAND;
use super::constants::GIT_SYMBOLIC_FULL_NAME_ARG;
use super::constants::GIT_SYMBOLIC_REF_COMMAND;
use super::constants::GIT_UPSTREAM_REF;
use super::constants::GIT_VERIFY_ARG;
use super::discovery;
use crate::config;
use crate::config::CargoPortConfig;
use crate::constants::GIT_REMOTE_SUFFIX;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum GitOrigin {
Local,
Clone,
Fork,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub(crate) enum WorkflowPresence {
Present,
#[default]
Missing,
}
impl WorkflowPresence {
pub const fn is_present(self) -> bool { matches!(self, Self::Present) }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum RemoteKind {
Clone,
Fork,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum KnownSentinel {
Disabled,
NoPush,
DoNotPush,
}
impl KnownSentinel {
pub const fn label(self) -> &'static str {
match self {
Self::Disabled => "DISABLED",
Self::NoPush => "no-push",
Self::DoNotPush => "do_not_push",
}
}
fn from_pushurl(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"disabled" => Some(Self::Disabled),
"no-push" => Some(Self::NoPush),
"do_not_push" => Some(Self::DoNotPush),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub(crate) enum PushDisabledReason {
KnownSentinel(KnownSentinel),
NoPushUrl,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "state")]
pub(crate) enum PushState {
Enabled { push_url: String },
Disabled { reason: PushDisabledReason },
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct RemoteInfo {
pub name: String,
pub url: Option<String>,
pub owner: Option<String>,
pub repo: Option<String>,
pub tracked_ref: Option<String>,
pub ahead_behind: Option<(usize, usize)>,
pub kind: RemoteKind,
pub push: PushState,
}
#[derive(Debug, Clone, Default, Serialize)]
pub(crate) struct RepoInfo {
pub remotes: Vec<RemoteInfo>,
pub workflows: WorkflowPresence,
pub first_commit: Option<String>,
pub last_fetched: Option<String>,
pub default_branch: Option<String>,
pub local_main_branch: Option<String>,
}
impl RepoInfo {
pub fn origin_kind(&self) -> GitOrigin {
if self.remotes.is_empty() {
GitOrigin::Local
} else if self.remotes.iter().any(|r| r.name == GIT_REMOTE_UPSTREAM) {
GitOrigin::Fork
} else {
GitOrigin::Clone
}
}
pub fn get(probe_path: &Path) -> Option<Self> {
let repo_root = discovery::git_repo_root(probe_path)?;
let active_config = config::active_config();
let branch = get_current_branch(&repo_root);
let current_upstream = get_upstream_branch(&repo_root);
let default_branch = get_default_branch(&repo_root);
let local_main_branch = resolve_local_main_branch(&repo_root);
let remote_names = list_remote_names(&repo_root);
let has_upstream = remote_names.iter().any(|n| n == GIT_REMOTE_UPSTREAM);
let pushurls = list_remote_pushurls(&repo_root);
let remote_context = RemoteResolveContext {
repo_root: &repo_root,
has_upstream,
current_upstream: current_upstream.as_deref(),
default_branch: default_branch.as_deref(),
current_branch: branch.as_deref(),
config: &active_config,
};
let remotes: Vec<RemoteInfo> = remote_names
.iter()
.map(|name| {
build_remote_info(
&remote_context,
name,
pushurls.get(name.as_str()).map(String::as_str),
)
})
.collect();
Some(Self {
remotes,
workflows: get_workflow_presence(&repo_root),
first_commit: None,
last_fetched: get_last_fetched(&repo_root),
default_branch,
local_main_branch,
})
}
}
pub(super) fn get_current_branch(repo_root: &Path) -> Option<String> {
command::git_output_logged(
repo_root,
"rev_parse_head",
[GIT_REV_PARSE_COMMAND, GIT_ABBREV_REF_ARG, GIT_HEAD],
)
.ok()
.and_then(|o| {
let b = String::from_utf8_lossy(&o.stdout).trim().to_string();
if b.is_empty() { None } else { Some(b) }
})
}
pub(super) fn get_upstream_branch(project_dir: &Path) -> Option<String> {
command::git_output_logged(
project_dir,
"rev_parse_upstream_name",
[
GIT_REV_PARSE_COMMAND,
GIT_ABBREV_REF_ARG,
GIT_SYMBOLIC_FULL_NAME_ARG,
GIT_UPSTREAM_REF,
],
)
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
})
}
fn get_default_branch(repo_root: &Path) -> Option<String> {
command::git_output_logged(
repo_root,
"symbolic_ref_origin_head",
[GIT_SYMBOLIC_REF_COMMAND, GIT_ORIGIN_HEAD_REF, GIT_SHORT_ARG],
)
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
s.strip_prefix(GIT_REMOTE_ORIGIN_PREFIX)
.filter(|b| !b.is_empty())
.map(str::to_string)
})
}
fn list_remote_names(repo_root: &Path) -> Vec<String> {
command::git_output_logged(repo_root, "remote", [GIT_REMOTE_COMMAND])
.ok()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default()
}
struct RemoteResolveContext<'a> {
repo_root: &'a Path,
has_upstream: bool,
current_upstream: Option<&'a str>,
default_branch: Option<&'a str>,
current_branch: Option<&'a str>,
config: &'a CargoPortConfig,
}
fn build_remote_info(
context: &RemoteResolveContext<'_>,
name: &str,
pushurl: Option<&str>,
) -> RemoteInfo {
let (owner, url, repo) = remote_url_info(context.repo_root, name);
let tracked_ref = resolve_tracked_ref(
context.repo_root,
name,
context.current_upstream,
context.default_branch,
context.current_branch,
context.config,
);
let ahead_behind = tracked_ref.as_deref().and_then(|r| {
checkout::parse_ahead_behind(
context.repo_root,
&format!("{GIT_HEAD_REVSPEC_PREFIX}{r}"),
&format!("tracked_{name}"),
)
});
let kind = if name == GIT_REMOTE_ORIGIN && context.has_upstream {
RemoteKind::Fork
} else {
RemoteKind::Clone
};
let push = resolve_push_state(url.as_deref(), pushurl);
RemoteInfo {
name: name.to_string(),
url,
owner,
repo,
tracked_ref,
ahead_behind,
kind,
push,
}
}
fn resolve_push_state(fetch_url: Option<&str>, pushurl: Option<&str>) -> PushState {
let push_url_for_fetch = || PushState::Enabled {
push_url: fetch_url.unwrap_or_default().to_string(),
};
let Some(value) = pushurl else {
return push_url_for_fetch();
};
let trimmed = value.trim();
if trimmed.is_empty() {
return PushState::Disabled {
reason: PushDisabledReason::NoPushUrl,
};
}
if let Some(sentinel) = KnownSentinel::from_pushurl(trimmed) {
return PushState::Disabled {
reason: PushDisabledReason::KnownSentinel(sentinel),
};
}
PushState::Enabled {
push_url: trimmed.to_string(),
}
}
fn list_remote_pushurls(repo_root: &Path) -> HashMap<String, String> {
let mut map = HashMap::new();
let Ok(output) = command::git_output_logged(
repo_root,
"config_get_regexp_pushurl",
[
GIT_CONFIG_COMMAND,
GIT_GET_REGEXP_ARG,
GIT_CONFIG_REMOTE_PUSHURL_PATTERN,
],
) else {
return map;
};
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let Some((key, value)) = line.split_once(' ') else {
continue;
};
let Some(rest) = key.strip_prefix(GIT_CONFIG_REMOTE_PREFIX) else {
continue;
};
let Some(name) = rest.strip_suffix(GIT_CONFIG_REMOTE_PUSHURL_SUFFIX) else {
continue;
};
map.insert(name.to_string(), value.to_string());
}
map
}
fn remote_url_info(
repo_root: &Path,
name: &str,
) -> (Option<String>, Option<String>, Option<String>) {
command::git_output_logged(
repo_root,
&format!("remote_get_url_{name}"),
[GIT_REMOTE_COMMAND, GIT_GET_URL_ARG, name],
)
.ok()
.map_or((None, None, None), |out| {
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
parse_remote_url(&raw)
})
}
fn resolve_tracked_ref(
repo_root: &Path,
remote_name: &str,
current_upstream: Option<&str>,
default_branch: Option<&str>,
current_branch: Option<&str>,
cfg: &CargoPortConfig,
) -> Option<String> {
let prefix = format!("{remote_name}/");
if let Some(us) = current_upstream
&& us.starts_with(&prefix)
{
return Some(us.to_string());
}
if let Ok(out) = command::git_output_logged(
repo_root,
&format!("symbolic_ref_{remote_name}_head"),
[
GIT_SYMBOLIC_REF_COMMAND,
&format!("{GIT_REMOTE_REF_PREFIX}{remote_name}{GIT_REMOTE_HEAD_REF_SUFFIX}"),
GIT_SHORT_ARG,
],
) {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.starts_with(&prefix) {
return Some(s);
}
}
if let Some(db) = default_branch
&& remote_ref_exists(repo_root, remote_name, db)
{
return Some(format!("{remote_name}/{db}"));
}
if let Some(cb) = current_branch
&& remote_ref_exists(repo_root, remote_name, cb)
{
return Some(format!("{remote_name}/{cb}"));
}
std::iter::once(cfg.tui.main_branch.as_str())
.chain(cfg.tui.other_primary_branches.iter().map(String::as_str))
.find(|b| remote_ref_exists(repo_root, remote_name, b))
.map(|b| format!("{remote_name}/{b}"))
}
fn remote_ref_exists(repo_root: &Path, remote_name: &str, branch: &str) -> bool {
command::git_output_logged(
repo_root,
&format!("show_ref_{remote_name}"),
[
GIT_SHOW_REF_COMMAND,
GIT_VERIFY_ARG,
GIT_QUIET_ARG,
&format!("{GIT_REMOTE_REF_PREFIX}{remote_name}/{branch}"),
],
)
.is_ok()
}
fn resolve_local_main_branch(project_dir: &Path) -> Option<String> {
let cfg = config::active_config();
std::iter::once(cfg.tui.main_branch.as_str())
.chain(cfg.tui.other_primary_branches.iter().map(String::as_str))
.find(|branch| local_branch_exists(project_dir, branch))
.map(str::to_string)
}
fn local_branch_exists(project_dir: &Path, branch: &str) -> bool {
command::git_output_logged(
project_dir,
"show_ref_local_main",
[
GIT_SHOW_REF_COMMAND,
GIT_VERIFY_ARG,
GIT_QUIET_ARG,
&format!("{GIT_LOCAL_BRANCH_REF_PREFIX}{branch}"),
],
)
.is_ok()
}
fn get_workflow_presence(repo_root: &Path) -> WorkflowPresence {
let workflows_dir = repo_root.join(".github").join("workflows");
let has_yaml = std::fs::read_dir(workflows_dir).is_ok_and(|entries| {
entries.filter_map(Result::ok).any(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
name.ends_with(".yml") || name.ends_with(".yaml")
})
});
if has_yaml {
WorkflowPresence::Present
} else {
WorkflowPresence::Missing
}
}
pub(crate) fn get_first_commit(project_dir: &Path) -> Option<String> {
let repo_root = discovery::git_repo_root(project_dir)?;
command::git_output_logged(
&repo_root,
"log_first_commit",
[
GIT_LOG_COMMAND,
GIT_MAX_PARENTS_ZERO_ARG,
GIT_REVERSE_ARG,
GIT_FORMAT_ISO8601_ARG,
GIT_HEAD,
],
)
.ok()
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.next()
.filter(|s| !s.is_empty())
.map(std::string::ToString::to_string)
})
}
fn get_last_fetched(repo_root: &Path) -> Option<String> {
let common_dir = discovery::resolve_common_git_dir(repo_root)?;
let fetch_head = common_dir.join("FETCH_HEAD");
let modified = std::fs::metadata(&fetch_head).ok()?.modified().ok()?;
system_time_to_iso8601_utc(modified)
}
fn system_time_to_iso8601_utc(t: SystemTime) -> Option<String> {
let secs = i64::try_from(
t.duration_since(std::time::SystemTime::UNIX_EPOCH)
.ok()?
.as_secs(),
)
.ok()?;
let days = secs.div_euclid(86_400);
let time_of_day = secs.rem_euclid(86_400);
let hour = time_of_day / 3_600;
let min = (time_of_day % 3_600) / 60;
let sec = time_of_day % 60;
let (year, month, day) = civil_from_days(days);
Some(format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z"
))
}
#[allow(
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
reason = "Hinnant's algorithm bounces between signed/unsigned; month/day always 1..=12 / 1..=31"
)]
const fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
(year, m as u32, d as u32)
}
fn parse_remote_url(raw: &str) -> (Option<String>, Option<String>, Option<String>) {
if let Some(after_at) = raw.strip_prefix("git@")
&& let Some((host, path)) = after_at.split_once(':')
{
let path = path.strip_suffix(GIT_REMOTE_SUFFIX).unwrap_or(path);
let mut parts = path.splitn(2, '/');
let owner = parts.next().map(String::from);
let repo = parts.next().map(String::from);
let url = format!("https://{host}/{path}");
return (owner, Some(url), repo);
}
if raw.starts_with("https://") || raw.starts_with("http://") {
let clean = raw.strip_suffix(GIT_REMOTE_SUFFIX).unwrap_or(raw);
let mut segments = clean.split('/').skip(3);
let owner = segments.next().map(String::from);
let repo = segments.next().map(String::from);
return (owner, Some(clean.to_string()), repo);
}
(None, None, None)
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests should panic on unexpected values"
)]
mod tests {
use serde_json::Value;
use super::*;
#[test]
fn push_state_unset_uses_fetch_url() {
let push = resolve_push_state(Some("https://github.com/a/b.git"), None);
assert_eq!(
push,
PushState::Enabled {
push_url: "https://github.com/a/b.git".to_string(),
}
);
}
#[test]
fn push_state_empty_is_no_push_url() {
let push = resolve_push_state(Some("https://github.com/a/b.git"), Some(""));
assert_eq!(
push,
PushState::Disabled {
reason: PushDisabledReason::NoPushUrl,
}
);
}
#[test]
fn push_state_disabled_sentinel_case_insensitive() {
for value in ["DISABLED", "disabled", "Disabled"] {
let push = resolve_push_state(Some("ignored"), Some(value));
assert_eq!(
push,
PushState::Disabled {
reason: PushDisabledReason::KnownSentinel(KnownSentinel::Disabled),
}
);
}
}
#[test]
fn push_state_unknown_pushurl_stays_enabled() {
let push = resolve_push_state(Some("https://github.com/a/b.git"), Some("ssh://other/repo"));
assert_eq!(
push,
PushState::Enabled {
push_url: "ssh://other/repo".to_string(),
}
);
}
#[test]
fn push_state_serde_round_trip() {
for state in [
PushState::Enabled {
push_url: "https://example.com".to_string(),
},
PushState::Disabled {
reason: PushDisabledReason::NoPushUrl,
},
PushState::Disabled {
reason: PushDisabledReason::KnownSentinel(KnownSentinel::Disabled),
},
] {
let json = serde_json::to_string(&state).expect("serialize");
let _: Value = serde_json::from_str(&json).expect("valid JSON");
}
}
}