use std::io::IsTerminal;
use std::path::PathBuf;
use std::process::Command;
use anyhow::{anyhow, Result};
use git2::build::{CheckoutBuilder, RepoBuilder};
use git2::{Cred, FetchOptions, RemoteCallbacks, Repository as GitRepository};
use inquire::{Confirm, Select, Text};
use serde::Serialize;
use crate::cli::{
Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs,
RepoDeleteArgs, RepoInfoArgs, RepoListArgs, RepoRefArgs,
};
pub async fn run(cli: &Cli, cmd: RepoCommand) -> Result<()> {
match cmd {
RepoCommand::List(args) => list(cli, args).await,
RepoCommand::Create(args) => create(args).await,
RepoCommand::Clone(args) => clone(args).await,
RepoCommand::Info(args) => info(cli, args).await,
RepoCommand::Delete(args) => delete(args).await,
RepoCommand::Star(args) => star(args).await,
RepoCommand::Unstar(args) => unstar(args).await,
}
}
async fn list(cli: &Cli, args: RepoListArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let effective_user =
args.user.as_deref().unwrap_or(session.handle.as_str());
let repos = crate::ops::repo::list_repos(
&pds,
Some(effective_user),
args.knot.as_deref(),
args.starred,
&auth,
)
.await?;
match cli.format {
OutputFormat::Json | OutputFormat::Yaml => {
crate::util::print_serialized(cli.format, &repos)?;
}
OutputFormat::Table => {
crate::util::print_table(
["NAME", "KNOT", "VISIBILITY"],
repos.into_iter().map(|repo| {
[
repo.name,
repo.knot.unwrap_or_default(),
if repo.private { "private" } else { "public" }
.to_string(),
]
}),
);
}
}
Ok(())
}
async fn create(mut args: RepoCreateArgs) -> Result<()> {
let interactive = args.name.is_none() && std::io::stdin().is_terminal();
let mode = if interactive {
prompt_create_mode()?
} else {
RepoCreateMode::Scratch
};
let local_repo = if mode == RepoCreateMode::ExistingLocal {
Some(GitRepository::discover(".")?)
} else {
None
};
let default_name = local_repo.as_ref().and_then(default_repo_name);
let name = resolve_create_name(args.name.take(), default_name.as_deref())?;
let description =
resolve_create_description(args.description.take(), interactive)?;
let default_branch = local_repo.as_ref().and_then(current_branch_name);
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let knot = args
.knot
.or_else(|| std::env::var("TANGLED_DEFAULT_KNOT").ok())
.unwrap_or_else(|| crate::ops::DEFAULT_KNOT_HOST.to_string());
let knot = knot
.trim_end_matches('/')
.trim_start_matches("https://")
.trim_start_matches("http://")
.to_string();
let opts = crate::ops::types::CreateRepoOptions {
did: &session.did,
name: &name,
knot: &knot,
description: description.as_deref(),
default_branch: default_branch.as_deref(),
source: None,
pds_base: &pds,
auth: &auth,
};
crate::ops::repo::create_repo(&knot, opts).await?;
println!("Created repo '{}' (knot: {})", name, knot);
if let Some(repo) = local_repo.as_ref() {
maybe_push_existing_repo(repo, &knot, &session.handle, &name).await?;
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RepoCreateMode {
Scratch,
ExistingLocal,
}
fn prompt_create_mode() -> Result<RepoCreateMode> {
let scratch = "Create a new repository on Tangled from scratch";
let existing = "Push an existing local repository to Tangled";
let selected =
Select::new("What would you like to do?", vec![scratch, existing])
.prompt()?;
Ok(if selected == existing {
RepoCreateMode::ExistingLocal
} else {
RepoCreateMode::Scratch
})
}
fn resolve_create_name(
provided: Option<String>,
default: Option<&str>,
) -> Result<String> {
let name = match provided {
Some(name) => name,
None if std::io::stdin().is_terminal() => {
let prompt = Text::new("Repository name");
match default {
Some(default) if !default.is_empty() => {
prompt.with_default(default).prompt()?
}
_ => prompt.prompt()?,
}
}
None => return Err(anyhow!("repository name is required")),
};
let name = name.trim().to_string();
if name.is_empty() {
return Err(anyhow!("repository name cannot be empty"));
}
Ok(name)
}
fn resolve_create_description(
provided: Option<String>,
interactive: bool,
) -> Result<Option<String>> {
if provided.is_some() || !interactive {
return Ok(provided);
}
let description = Text::new("Description (optional)").prompt()?;
Ok((!description.trim().is_empty()).then_some(description))
}
fn default_repo_name(repo: &GitRepository) -> Option<String> {
repo.workdir()
.and_then(|path| path.file_name())
.and_then(|name| name.to_str())
.map(str::to_string)
}
fn current_branch_name(repo: &GitRepository) -> Option<String> {
repo.head()
.ok()
.and_then(|head| head.shorthand().map(str::to_string))
}
async fn maybe_push_existing_repo(
repo: &GitRepository,
knot: &str,
handle: &str,
name: &str,
) -> Result<()> {
let branch = current_branch_name(repo).ok_or_else(|| {
anyhow!("cannot push local repository while HEAD is detached")
})?;
let remote_name = if repo.find_remote("origin").is_ok() {
"tangled"
} else {
"origin"
};
let remote_url = ssh_remote_url(knot, handle, name);
if !Confirm::new("Add a git remote and push the current branch?")
.with_default(true)
.prompt()?
{
println!("To push this repository later:");
println!(" git remote add {} {}", remote_name, remote_url);
println!(" git push -u {} {}", remote_name, branch);
return Ok(());
}
match repo.find_remote(remote_name) {
Ok(remote) => {
let existing = remote.url().unwrap_or_default();
if existing != remote_url {
return Err(anyhow!(
"remote '{}' already exists with URL {}; expected {}",
remote_name,
existing,
remote_url
));
}
}
Err(_) => {
run_git_with_spinner(
"Adding git remote...",
vec!["remote", "add", remote_name, remote_url.as_str()],
)
.await?;
}
}
run_git_with_spinner(
"Pushing current branch...",
vec!["push", "-u", remote_name, branch.as_str()],
)
.await?;
println!("Pushed '{}' to remote '{}'.", branch, remote_name);
Ok(())
}
fn ssh_remote_url(knot: &str, handle: &str, name: &str) -> String {
let host = if knot == crate::ops::DEFAULT_KNOT_HOST {
"tangled.org"
} else {
knot
};
format!("git@{}:{}/{}", host, handle.trim_start_matches('@'), name)
}
async fn run_git_with_spinner(message: &str, args: Vec<&str>) -> Result<()> {
let owned = args.into_iter().map(str::to_string).collect::<Vec<_>>();
let display = owned.join(" ");
crate::progress::with_spinner(message.to_string(), async move {
tokio::task::spawn_blocking(move || {
let status = Command::new("git").args(&owned).status()?;
if status.success() {
Ok(())
} else {
Err(anyhow!("git {} failed with {}", display, status))
}
})
.await?
})
.await
}
async fn clone(args: RepoCloneArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let info =
crate::ops::repo::get_repo_info(&pds, owner, &name, &auth).await?;
let remote = if args.https {
let owner_path = if owner.starts_with('@') {
owner.to_string()
} else {
format!("@{}", owner)
};
format!("https://tangled.org/{}/{}", owner_path, name)
} else {
let knot = if info.knot == "knot1.tangled.sh" {
"tangled.org".to_string()
} else {
info.knot.clone()
};
format!("git@{}:{}/{}", knot, owner.trim_start_matches('@'), name)
};
let target = PathBuf::from(&name);
println!("Cloning {} -> {:?}", remote, target);
let pb = crate::progress::progress_bar("Cloning repository...");
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed| {
if let Some(user) = username_from_url {
Cred::ssh_key_from_agent(user)
} else {
Cred::default()
}
});
let fetch_pb = pb.clone();
callbacks.transfer_progress(move |stats| {
let total = stats.total_objects() as u64;
if total > 0 {
fetch_pb.set_length(total);
fetch_pb.set_position(stats.received_objects() as u64);
fetch_pb.set_message("Receiving objects...");
}
true
});
let mut fetch_opts = FetchOptions::new();
fetch_opts.remote_callbacks(callbacks);
if let Some(d) = args.depth {
fetch_opts.depth(d as i32);
}
let checkout_pb = pb.clone();
let mut checkout = CheckoutBuilder::new();
checkout.progress(move |_path, completed, total| {
if total > 0 {
checkout_pb.set_length(total as u64);
checkout_pb.set_position(completed as u64);
checkout_pb.set_message("Checking out files...");
}
});
let mut builder = RepoBuilder::new();
builder.fetch_options(fetch_opts);
builder.with_checkout(checkout);
let result = builder.clone(&remote, &target);
pb.finish_and_clear();
match result {
Ok(_) => Ok(()),
Err(e) => {
println!("Failed to clone via libgit2: {}", e);
println!(
"Hint: try: git clone{} {}",
args.depth
.map(|d| format!(" --depth {}", d))
.unwrap_or_default(),
remote
);
Err(anyhow!(e.to_string()))
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RepoInfoOutput {
name: String,
owner_did: String,
rkey: String,
#[serde(skip_serializing_if = "Option::is_none")]
repo_did: Option<String>,
knot: String,
#[serde(skip_serializing_if = "Option::is_none")]
spindle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
default_branch: Option<crate::ops::types::DefaultBranch>,
#[serde(skip_serializing_if = "Option::is_none")]
languages: Option<crate::ops::types::Languages>,
}
async fn info(cli: &Cli, args: RepoInfoArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let info =
crate::ops::repo::get_repo_info(&pds, owner, &name, &auth).await?;
let (default_branch, languages) = if args.stats {
let default_branch = crate::ops::repo::get_default_branch(
&info.knot, &info.did, &info.name,
)
.await
.ok();
let languages =
crate::ops::repo::get_languages(&info.knot, &info.did, &info.name)
.await
.ok();
(default_branch, languages)
} else {
(None, None)
};
if matches!(cli.format, OutputFormat::Json | OutputFormat::Yaml) {
let output = RepoInfoOutput {
name: info.name,
owner_did: info.did,
rkey: info.rkey,
repo_did: info.repo_did,
knot: info.knot,
spindle: info.spindle,
description: info.description,
source: info.source,
default_branch,
languages,
};
return crate::util::print_serialized(cli.format, &output);
}
println!("NAME: {}", info.name);
println!("OWNER DID: {}", info.did);
println!("KNOT: {}", info.knot);
if let Some(spindle) = info.spindle.as_deref().filter(|s| !s.is_empty()) {
println!("SPINDLE: {}", spindle);
}
if let Some(desc) = info.description.as_deref().filter(|s| !s.is_empty()) {
println!("DESCRIPTION: {}", desc);
}
if let Some(def) = default_branch {
println!(
"DEFAULT BRANCH: {} ({})",
def.name,
def.short_hash.unwrap_or(def.hash)
);
if let Some(msg) = def.message.filter(|message| !message.is_empty()) {
println!("LAST COMMIT: {}", msg);
}
}
if let Some(langs) = languages.filter(|langs| !langs.languages.is_empty()) {
println!("LANGUAGES:");
for language in langs.languages.iter().take(6) {
println!(" - {} ({}%)", language.name, language.percentage);
}
}
if args.contributors {
println!("Contributors: not implemented yet");
}
Ok(())
}
async fn delete(args: RepoDeleteArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let record =
crate::ops::repo::get_repo_info(&pds, owner, &name, &auth).await?;
crate::ops::repo::delete_repo(
&record.knot,
&record.did,
&name,
&pds,
&auth,
)
.await?;
println!("Deleted repo '{}'", name);
Ok(())
}
async fn star(args: RepoRefArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let info =
crate::ops::repo::get_repo_info(&pds, owner, &name, &auth).await?;
let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
crate::ops::repo::star_repo(&pds, &auth, &subject, &session.did).await?;
println!("Starred {}/{}", owner, name);
Ok(())
}
async fn unstar(args: RepoRefArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let info =
crate::ops::repo::get_repo_info(&pds, owner, &name, &auth).await?;
let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
crate::ops::repo::unstar_repo(&pds, &auth, &subject, &session.did).await?;
println!("Unstarred {}/{}", owner, name);
Ok(())
}
fn parse_repo_ref<'a>(
spec: &'a str,
default_owner: &'a str,
) -> (&'a str, String) {
if let Some((owner, name)) = spec.split_once('/') {
(owner, name.to_string())
} else {
(default_owner, spec.to_string())
}
}