use anyhow::{anyhow, Result};
use git2::Repository;
use serde::Serialize;
use tabled::builder::Builder;
use tabled::settings::Style;
use tangled_config::session::{Session, SessionManager};
use crate::cli::OutputFormat;
pub fn print_serialized<T: Serialize + ?Sized>(
format: OutputFormat,
value: &T,
) -> Result<()> {
print!("{}", render_serialized(format, value)?);
Ok(())
}
fn render_serialized<T: Serialize + ?Sized>(
format: OutputFormat,
value: &T,
) -> Result<String> {
match format {
OutputFormat::Json => {
Ok(format!("{}\n", serde_json::to_string_pretty(value)?))
}
OutputFormat::Yaml => Ok(serde_yaml::to_string(value)?),
OutputFormat::Table => {
Err(anyhow!("table output requires an explicit table layout"))
}
}
}
pub fn print_table<const C: usize>(
headers: [&str; C],
rows: impl IntoIterator<Item = [String; C]>,
) {
println!("{}", render_table(headers, rows));
}
fn render_table<const C: usize>(
headers: [&str; C],
rows: impl IntoIterator<Item = [String; C]>,
) -> String {
let mut builder = Builder::default();
builder.push_record(headers);
for row in rows {
builder.push_record(row.map(|cell| single_line(&cell)));
}
let mut table = builder.build();
table.with(Style::blank());
table
.to_string()
.lines()
.map(str::trim)
.collect::<Vec<_>>()
.join("\n")
}
fn single_line(value: &str) -> String {
value.replace(['\t', '\r', '\n'], " ")
}
pub async fn load_session() -> Result<Session> {
let mgr = SessionManager::default();
let session = mgr
.load()?
.ok_or_else(|| anyhow!("Please login first: tang auth login"))?;
Ok(session)
}
pub async fn refresh_session(session: &Session) -> Result<Session> {
let mut new_session = if session.oauth.is_some() {
crate::ops::oauth::refresh(session).await?
} else {
let pds = session
.pds
.clone()
.unwrap_or_else(|| "https://bsky.social".to_string());
crate::ops::session::refresh_session(&pds, &session.refresh_jwt).await?
};
new_session.pds = session.pds.clone();
let mgr = SessionManager::default();
mgr.save(&new_session)?;
Ok(new_session)
}
pub async fn load_session_with_refresh() -> Result<Session> {
let session = load_session().await?;
let stale = match &session.oauth {
Some(oauth) => chrono::Utc::now().timestamp() > oauth.expires_at - 120,
None => {
chrono::Utc::now()
.signed_duration_since(session.created_at)
.num_minutes()
> 30
}
};
if stale {
match refresh_session(&session).await {
Ok(new_session) => return Ok(new_session),
Err(_) => {
}
}
}
Ok(session)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRepoContext {
pub owner: String,
pub name: String,
pub host: String,
pub remote_name: String,
pub remote_url: String,
pub current_branch: Option<String>,
pub default_branch: Option<String>,
}
impl GitRepoContext {
pub fn repo_spec(&self) -> String {
format!("{}/{}", self.owner, self.name)
}
}
pub fn current_git_repo_context() -> Result<GitRepoContext> {
let repo = Repository::discover(".")?;
let current_branch = current_branch(&repo);
let (remote_name, remote) =
select_tangled_remote(&repo, current_branch.as_deref())?;
let remote_url = remote
.url()
.ok_or_else(|| {
anyhow!("could not infer repository: remote has no URL")
})?
.to_string();
let (owner, name, host) = parse_tangled_remote_url(&remote_url)
.ok_or_else(|| {
anyhow!("could not parse Tangled remote URL: {}", remote_url)
})?;
Ok(GitRepoContext {
owner,
name,
host,
remote_name,
remote_url,
current_branch,
default_branch: default_branch(&repo),
})
}
pub fn parse_tangled_remote_url(url: &str) -> Option<(String, String, String)> {
if let Some(rest) = url.strip_prefix("git@") {
let mut parts = rest.splitn(2, ':');
let host = normalize_host(parts.next()?);
let path = parts.next()?;
if !is_tangled_host(&host) {
return None;
}
return parse_remote_path(path)
.map(|(owner, name)| (owner, name, host));
}
if url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("ssh://")
{
let parsed = url::Url::parse(url).ok()?;
let host = normalize_host(parsed.host_str()?);
if !is_tangled_host(&host) {
return None;
}
return parse_remote_path(parsed.path())
.map(|(owner, name)| (owner, name, host));
}
None
}
fn parse_remote_path(path: &str) -> Option<(String, String)> {
let mut segs = path.trim_matches('/').trim_end_matches(".git").split('/');
let owner = segs.next()?.trim_start_matches('@');
let name = segs.next()?;
if owner.is_empty() || name.is_empty() || segs.next().is_some() {
return None;
}
Some((owner.to_string(), name.to_string()))
}
fn normalize_host(host: &str) -> String {
host.trim_end_matches('/').to_ascii_lowercase()
}
fn is_tangled_host(host: &str) -> bool {
host == "tangled.org" || host.ends_with(".tangled.sh")
}
fn select_tangled_remote<'repo>(
repo: &'repo Repository,
current_branch: Option<&str>,
) -> Result<(String, git2::Remote<'repo>)> {
if let Some(branch) = current_branch {
if let Ok(local_branch) =
repo.find_branch(branch, git2::BranchType::Local)
{
if let Ok(upstream) = local_branch.upstream() {
if let Some(remote_name) = upstream_remote_name(repo, &upstream)
{
let remote = repo.find_remote(&remote_name)?;
if remote.url().and_then(parse_tangled_remote_url).is_some()
{
return Ok((remote_name, remote));
}
}
}
}
}
if let Ok(remote) = repo.find_remote("origin") {
if remote.url().and_then(parse_tangled_remote_url).is_some() {
return Ok(("origin".to_string(), remote));
}
}
let mut tangled = vec![];
let remotes = repo.remotes().map_err(|_| {
anyhow!("could not infer repository: no git remotes configured")
})?;
for name in remotes.iter().flatten() {
let remote = repo.find_remote(name)?;
if remote.url().and_then(parse_tangled_remote_url).is_some() {
tangled.push(name.to_string());
}
}
match tangled.as_slice() {
[] => Err(anyhow!(
"could not infer repository: no Tangled git remote found; pass --repo"
)),
[name] => Ok((name.clone(), repo.find_remote(name)?)),
names => Err(anyhow!(
"could not infer repository: multiple Tangled remotes found ({}); pass --repo",
names.join(", ")
)),
}
}
fn upstream_remote_name(
repo: &Repository,
upstream: &git2::Branch<'_>,
) -> Option<String> {
let upstream_ref = upstream.get().name()?;
let remotes = repo.remotes().ok()?;
for remote in remotes.iter().flatten() {
let prefix = format!("refs/remotes/{}/", remote);
if upstream_ref.starts_with(&prefix) {
return Some(remote.to_string());
}
}
None
}
fn current_branch(repo: &Repository) -> Option<String> {
repo.head()
.ok()
.and_then(|head| head.shorthand().map(ToOwned::to_owned))
}
fn default_branch(repo: &Repository) -> Option<String> {
repo.find_reference("refs/remotes/origin/HEAD")
.ok()
.and_then(|reference| {
reference.symbolic_target().map(ToOwned::to_owned)
})
.and_then(|target| {
target
.strip_prefix("refs/remotes/origin/")
.map(ToOwned::to_owned)
})
.or_else(|| {
["main", "master"]
.into_iter()
.find(|branch| {
repo.find_reference(&format!(
"refs/remotes/origin/{}",
branch
))
.is_ok()
})
.map(ToOwned::to_owned)
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{parse_tangled_remote_url, render_serialized, render_table};
use crate::cli::OutputFormat;
#[test]
fn renders_json_and_yaml_output() {
let value = json!({"name": "demo", "private": false});
let json = render_serialized(OutputFormat::Json, &value).unwrap();
let yaml = render_serialized(OutputFormat::Yaml, &value).unwrap();
assert_eq!(json, "{\n \"name\": \"demo\",\n \"private\": false\n}\n");
assert_eq!(yaml, "name: demo\nprivate: false\n");
}
#[test]
fn table_output_aligns_columns_and_keeps_records_on_one_line() {
let output = render_table(
["NAME", "VALUE"],
[
["short".to_string(), "one".to_string()],
["longer name".to_string(), "two\nlines".to_string()],
],
);
let lines = output.lines().collect::<Vec<_>>();
let value_column = lines[0].find("VALUE").unwrap();
assert_eq!(lines.len(), 3);
assert!(!output.contains(['\t', '\r']));
assert_eq!(lines[1].find("one"), Some(value_column));
assert_eq!(lines[2].find("two lines"), Some(value_column));
}
#[test]
fn parses_tangled_https_remotes() {
assert_eq!(
parse_tangled_remote_url(
"https://tangled.org/@alice.example/demo.git"
),
Some((
"alice.example".to_string(),
"demo".to_string(),
"tangled.org".to_string()
))
);
assert_eq!(
parse_tangled_remote_url("https://tangled.org/alice.example/demo"),
Some((
"alice.example".to_string(),
"demo".to_string(),
"tangled.org".to_string()
))
);
}
#[test]
fn parses_tangled_ssh_remotes() {
assert_eq!(
parse_tangled_remote_url(
"git@knot1.tangled.sh:alice.example/demo.git"
),
Some((
"alice.example".to_string(),
"demo".to_string(),
"knot1.tangled.sh".to_string()
))
);
}
#[test]
fn parses_tangled_ssh_url_remotes() {
assert_eq!(
parse_tangled_remote_url(
"ssh://git@knot1.tangled.sh/alice.example/demo.git"
),
Some((
"alice.example".to_string(),
"demo".to_string(),
"knot1.tangled.sh".to_string()
))
);
}
#[test]
fn rejects_non_tangled_and_extra_path_remotes() {
assert_eq!(
parse_tangled_remote_url("git@github.com:alice/demo.git"),
None
);
assert_eq!(
parse_tangled_remote_url("https://tangled.org/alice/demo/extra"),
None
);
}
}