tangled-cli 0.1.0

CLI for interacting with Tangled, an AT Protocol-based git collaboration platform
use std::path::Path;

use anyhow::{anyhow, Result};
use git2::{Direction, Repository as GitRepository, StatusOptions};

use crate::cli::{Cli, KnotCommand, KnotMigrateArgs};

pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
    match cmd {
        KnotCommand::Migrate(args) => migrate(args).await,
    }
}

async fn migrate(args: KnotMigrateArgs) -> Result<()> {
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
    // 1) Ensure we're inside a git repository and working tree is clean
    let repo = GitRepository::discover(Path::new("."))?;
    let mut status_opts = StatusOptions::new();
    status_opts.include_untracked(false).include_ignored(false);
    let statuses = repo.statuses(Some(&mut status_opts))?;
    if !statuses.is_empty() {
        return Err(anyhow!(
            "working tree has uncommitted changes; commit/push before migrating"
        ));
    }

    // 2) Derive current branch and ensure it's pushed to origin
    let head = match repo.head() {
        Ok(h) => h,
        Err(_) => return Err(anyhow!("repository does not have a HEAD")),
    };
    let head_oid = head
        .target()
        .ok_or_else(|| anyhow!("failed to resolve HEAD OID"))?;
    let head_name = head.shorthand().unwrap_or("");
    let full_ref = head.name().unwrap_or("").to_string();
    if !full_ref.starts_with("refs/heads/") {
        return Err(anyhow!(
            "HEAD is detached; please checkout a branch before migrating"
        ));
    }
    let branch = head_name.to_string();

    let origin = repo.find_remote("origin").or_else(|_| {
        repo.remotes().and_then(|rems| {
            rems.get(0)
                .ok_or(git2::Error::from_str("no remotes configured"))
                .and_then(|name| repo.find_remote(name))
        })
    })?;

    // Connect and list remote heads to find refs/heads/<branch>
    let mut remote = origin;
    remote.connect(Direction::Fetch)?;
    let remote_heads = remote.list()?;
    let remote_oid = remote_heads
        .iter()
        .find_map(|h| {
            if h.name() == format!("refs/heads/{}", branch) {
                Some(h.oid())
            } else {
                None
            }
        })
        .ok_or_else(|| {
            anyhow!("origin does not have branch '{}' — push first", branch)
        })?;
    if remote_oid != head_oid {
        return Err(anyhow!(
            "local {} ({}) != origin {} ({}); please push before migrating",
            branch,
            head_oid,
            branch,
            remote_oid
        ));
    }

    // 3) Parse origin URL to verify repo identity
    let origin_url = remote
        .url()
        .ok_or_else(|| anyhow!("origin has no URL"))?
        .to_string();
    let (origin_owner, origin_name, _origin_host) =
        parse_remote_url(&origin_url)
            .ok_or_else(|| anyhow!("unsupported origin URL: {}", origin_url))?;

    let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
    if origin_owner.trim_start_matches('@') != owner.trim_start_matches('@')
        || origin_name != name
    {
        return Err(anyhow!(
            "repo mismatch: current checkout '{}'/{} != argument '{}'/{}",
            origin_owner,
            origin_name,
            owner,
            name
        ));
    }

    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?;

    // Build a publicly accessible source URL on tangled.org for the existing repo
    let owner_path = if owner.starts_with('@') {
        owner.to_string()
    } else {
        format!("@{}", owner)
    };
    let source = if args.https {
        format!("https://tangled.org/{}/{}", owner_path, name)
    } else {
        format!(
            "git@{}:{}/{}",
            info.knot,
            owner.trim_start_matches('@'),
            name
        )
    };

    // Create the repo on the target knot, seeding from source
    let opts = crate::ops::types::CreateRepoOptions {
        did: &session.did,
        name: &name,
        knot: &args.to,
        description: info.description.as_deref(),
        default_branch: None,
        source: Some(&source),
        pds_base: &pds,
        auth: &auth,
    };
    crate::ops::repo::create_repo(crate::ops::DEFAULT_KNOT, opts).await?;

    // Update the PDS record to point to the new knot
    if args.update_record {
        crate::ops::repo::update_repo_knot(
            &pds,
            &session.did,
            &info.rkey,
            &args.to,
            &auth,
        )
        .await?;
    }

    println!("Migrated repo '{}' to knot {}", name, args.to);
    println!(
        "Note: old repository on {} is not deleted automatically.",
        info.knot
    );
    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())
    }
}

fn parse_remote_url(url: &str) -> Option<(String, String, String)> {
    // Returns (owner, name, host)
    if let Some(rest) = url.strip_prefix("git@") {
        // git@host:owner/name(.git)
        let mut parts = rest.split(':');
        let host = parts.next()?.to_string();
        let path = parts.next()?;
        let mut segs = path.trim_end_matches(".git").split('/');
        let owner = segs.next()?.to_string();
        let name = segs.next()?.to_string();
        return Some((owner, name, host));
    }
    if url.starts_with("http://") || url.starts_with("https://") {
        if let Ok(parsed) = url::Url::parse(url) {
            let host = parsed.host_str().unwrap_or("").to_string();
            let path = parsed.path().trim_matches('/');
            // paths may be like '@owner/name' or 'owner/name'
            let mut segs = path.trim_end_matches(".git").split('/');
            let first = segs.next()?;
            let owner = first.trim_start_matches('@').to_string();
            let name = segs.next()?.to_string();
            return Some((owner, name, host));
        }
    }
    None
}