More repo commands, migrate

Changed files
+801 -15
crates
tangled-api
src
tangled-cli
+2
Cargo.lock
···
"clap",
"colored",
"dialoguer",
+
"git2",
"indicatif",
"serde",
"serde_json",
···
"tangled-config",
"tangled-git",
"tokio",
+
"url",
[[package]]
+396 -1
crates/tangled-api/src/client.rs
···
#[derive(Deserialize)]
struct RecordItem {
+
uri: String,
value: Repository,
}
#[derive(Deserialize)]
···
let res: ListRes = self
.get_json("com.atproto.repo.listRecords", &params, bearer)
.await?;
-
let mut repos: Vec<Repository> = res.records.into_iter().map(|r| r.value).collect();
+
let mut repos: Vec<Repository> = res
+
.records
+
.into_iter()
+
.map(|r| {
+
let mut val = r.value;
+
if val.rkey.is_none() {
+
if let Some(k) = Self::uri_rkey(&r.uri) {
+
val.rkey = Some(k);
+
}
+
}
+
if val.did.is_none() {
+
if let Some(d) = Self::uri_did(&r.uri) {
+
val.did = Some(d);
+
}
+
}
+
val
+
})
+
.collect();
// Apply optional filters client-side
if let Some(k) = knot {
repos.retain(|r| r.knot.as_deref().unwrap_or("") == k);
···
let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?;
Ok(())
}
+
+
pub async fn get_repo_info(
+
&self,
+
owner: &str,
+
name: &str,
+
bearer: Option<&str>,
+
) -> Result<RepoRecord> {
+
let did = if owner.starts_with("did:") {
+
owner.to_string()
+
} else {
+
#[derive(Deserialize)]
+
struct Res {
+
did: String,
+
}
+
let params = [("handle", owner.to_string())];
+
let res: Res = self
+
.get_json("com.atproto.identity.resolveHandle", &params, bearer)
+
.await?;
+
res.did
+
};
+
+
#[derive(Deserialize)]
+
struct RecordItem {
+
uri: String,
+
value: Repository,
+
}
+
#[derive(Deserialize)]
+
struct ListRes {
+
#[serde(default)]
+
records: Vec<RecordItem>,
+
}
+
let params = vec![
+
("repo", did.clone()),
+
("collection", "sh.tangled.repo".to_string()),
+
("limit", "100".to_string()),
+
];
+
let res: ListRes = self
+
.get_json("com.atproto.repo.listRecords", &params, bearer)
+
.await?;
+
for item in res.records {
+
if item.value.name == name {
+
let rkey =
+
Self::uri_rkey(&item.uri).ok_or_else(|| anyhow!("missing rkey in uri"))?;
+
let knot = item.value.knot.unwrap_or_default();
+
return Ok(RepoRecord {
+
did: did.clone(),
+
name: name.to_string(),
+
rkey,
+
knot,
+
description: item.value.description,
+
});
+
}
+
}
+
Err(anyhow!("repo not found for owner/name"))
+
}
+
+
pub async fn delete_repo(
+
&self,
+
did: &str,
+
name: &str,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<()> {
+
let pds_client = TangledClient::new(pds_base);
+
let info = pds_client
+
.get_repo_info(did, name, Some(access_jwt))
+
.await?;
+
+
#[derive(Serialize)]
+
struct DeleteRecordReq<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
rkey: &'a str,
+
}
+
let del = DeleteRecordReq {
+
repo: did,
+
collection: "sh.tangled.repo",
+
rkey: &info.rkey,
+
};
+
let _: serde_json::Value = pds_client
+
.post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt))
+
.await?;
+
+
let host = self
+
.base_url
+
.trim_end_matches('/')
+
.strip_prefix("https://")
+
.or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://"))
+
.ok_or_else(|| anyhow!("invalid base_url"))?;
+
let audience = format!("did:web:{}", host);
+
#[derive(Deserialize)]
+
struct GetSARes {
+
token: String,
+
}
+
let params = [
+
("aud", audience),
+
("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
+
];
+
let sa: GetSARes = pds_client
+
.get_json(
+
"com.atproto.server.getServiceAuth",
+
&params,
+
Some(access_jwt),
+
)
+
.await?;
+
+
#[derive(Serialize)]
+
struct DeleteReq<'a> {
+
did: &'a str,
+
name: &'a str,
+
rkey: &'a str,
+
}
+
let body = DeleteReq {
+
did,
+
name,
+
rkey: &info.rkey,
+
};
+
let _: serde_json::Value = self
+
.post_json("sh.tangled.repo.delete", &body, Some(&sa.token))
+
.await?;
+
Ok(())
+
}
+
+
pub async fn update_repo_knot(
+
&self,
+
did: &str,
+
rkey: &str,
+
new_knot: &str,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<()> {
+
let pds_client = TangledClient::new(pds_base);
+
#[derive(Deserialize, Serialize, Clone)]
+
struct Rec {
+
name: String,
+
knot: String,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
description: Option<String>,
+
#[serde(rename = "createdAt")]
+
created_at: String,
+
}
+
#[derive(Deserialize)]
+
struct GetRes {
+
value: Rec,
+
}
+
let params = [
+
("repo", did.to_string()),
+
("collection", "sh.tangled.repo".to_string()),
+
("rkey", rkey.to_string()),
+
];
+
let got: GetRes = pds_client
+
.get_json("com.atproto.repo.getRecord", &params, Some(access_jwt))
+
.await?;
+
let mut rec = got.value;
+
rec.knot = new_knot.to_string();
+
#[derive(Serialize)]
+
struct PutReq<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
rkey: &'a str,
+
validate: bool,
+
record: Rec,
+
}
+
let req = PutReq {
+
repo: did,
+
collection: "sh.tangled.repo",
+
rkey,
+
validate: true,
+
record: rec,
+
};
+
let _: serde_json::Value = pds_client
+
.post_json("com.atproto.repo.putRecord", &req, Some(access_jwt))
+
.await?;
+
Ok(())
+
}
+
+
pub async fn get_default_branch(
+
&self,
+
knot_host: &str,
+
did: &str,
+
name: &str,
+
) -> Result<DefaultBranch> {
+
#[derive(Deserialize)]
+
struct Res {
+
name: String,
+
hash: String,
+
#[serde(rename = "shortHash")]
+
short_hash: Option<String>,
+
when: String,
+
message: Option<String>,
+
}
+
let knot_client = TangledClient::new(knot_host);
+
let repo_param = format!("{}/{}", did, name);
+
let params = [("repo", repo_param)];
+
let res: Res = knot_client
+
.get_json("sh.tangled.repo.getDefaultBranch", &params, None)
+
.await?;
+
Ok(DefaultBranch {
+
name: res.name,
+
hash: res.hash,
+
short_hash: res.short_hash,
+
when: res.when,
+
message: res.message,
+
})
+
}
+
+
pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> {
+
let knot_client = TangledClient::new(knot_host);
+
let repo_param = format!("{}/{}", did, name);
+
let params = [("repo", repo_param)];
+
let res: serde_json::Value = knot_client
+
.get_json("sh.tangled.repo.languages", &params, None)
+
.await?;
+
let langs = res
+
.get("languages")
+
.cloned()
+
.unwrap_or(serde_json::json!([]));
+
let languages: Vec<Language> = serde_json::from_value(langs)?;
+
let total_size = res.get("totalSize").and_then(|v| v.as_u64());
+
let total_files = res.get("totalFiles").and_then(|v| v.as_u64());
+
Ok(Languages {
+
languages,
+
total_size,
+
total_files,
+
})
+
}
+
+
pub async fn star_repo(
+
&self,
+
pds_base: &str,
+
access_jwt: &str,
+
subject_at_uri: &str,
+
user_did: &str,
+
) -> Result<String> {
+
#[derive(Serialize)]
+
struct Rec<'a> {
+
subject: &'a str,
+
#[serde(rename = "createdAt")]
+
created_at: String,
+
}
+
#[derive(Serialize)]
+
struct Req<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
validate: bool,
+
record: Rec<'a>,
+
}
+
#[derive(Deserialize)]
+
struct Res {
+
uri: String,
+
}
+
let now = chrono::Utc::now().to_rfc3339();
+
let rec = Rec {
+
subject: subject_at_uri,
+
created_at: now,
+
};
+
let req = Req {
+
repo: user_did,
+
collection: "sh.tangled.feed.star",
+
validate: true,
+
record: rec,
+
};
+
let pds_client = TangledClient::new(pds_base);
+
let res: Res = pds_client
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
+
.await?;
+
let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?;
+
Ok(rkey)
+
}
+
+
pub async fn unstar_repo(
+
&self,
+
pds_base: &str,
+
access_jwt: &str,
+
subject_at_uri: &str,
+
user_did: &str,
+
) -> Result<()> {
+
#[derive(Deserialize)]
+
struct Item {
+
uri: String,
+
value: StarRecord,
+
}
+
#[derive(Deserialize)]
+
struct ListRes {
+
#[serde(default)]
+
records: Vec<Item>,
+
}
+
let pds_client = TangledClient::new(pds_base);
+
let params = vec![
+
("repo", user_did.to_string()),
+
("collection", "sh.tangled.feed.star".to_string()),
+
("limit", "100".to_string()),
+
];
+
let res: ListRes = pds_client
+
.get_json("com.atproto.repo.listRecords", &params, Some(access_jwt))
+
.await?;
+
let mut rkey = None;
+
for item in res.records {
+
if item.value.subject == subject_at_uri {
+
rkey = Self::uri_rkey(&item.uri);
+
if rkey.is_some() {
+
break;
+
}
+
}
+
}
+
let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?;
+
#[derive(Serialize)]
+
struct Del<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
rkey: &'a str,
+
}
+
let del = Del {
+
repo: user_did,
+
collection: "sh.tangled.feed.star",
+
rkey: &rkey,
+
};
+
let _: serde_json::Value = pds_client
+
.post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt))
+
.await?;
+
Ok(())
+
}
+
+
fn uri_rkey(uri: &str) -> Option<String> {
+
uri.rsplit('/').next().map(|s| s.to_string())
+
}
+
fn uri_did(uri: &str) -> Option<String> {
+
let parts: Vec<&str> = uri.split('/').collect();
+
if parts.len() >= 3 {
+
Some(parts[2].to_string())
+
} else {
+
None
+
}
+
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
···
pub description: Option<String>,
#[serde(default)]
pub private: bool,
+
}
+
+
#[derive(Debug, Clone)]
+
pub struct RepoRecord {
+
pub did: String,
+
pub name: String,
+
pub rkey: String,
+
pub knot: String,
+
pub description: Option<String>,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct DefaultBranch {
+
pub name: String,
+
pub hash: String,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub short_hash: Option<String>,
+
pub when: String,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub message: Option<String>,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Language {
+
pub name: String,
+
pub size: u64,
+
pub percentage: u64,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Languages {
+
pub languages: Vec<Language>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub total_size: Option<u64>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub total_files: Option<u64>,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct StarRecord {
+
pub subject: String,
+
#[serde(rename = "createdAt")]
+
pub created_at: String,
}
#[derive(Debug, Clone)]
+2 -1
crates/tangled-cli/Cargo.toml
···
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
+
git2 = { workspace = true }
+
url = { workspace = true }
# Internal crates
tangled-config = { path = "../tangled-config" }
tangled-api = { path = "../tangled-api" }
tangled-git = { path = "../tangled-git" }
-
+18
crates/tangled-cli/src/cli.rs
···
Verify(KnotVerifyArgs),
SetDefault(KnotRefArgs),
Remove(KnotRefArgs),
+
/// Migrate a repository to another knot
+
Migrate(KnotMigrateArgs),
}
#[derive(Args, Debug, Clone)]
···
#[derive(Args, Debug, Clone)]
pub struct KnotRefArgs {
pub url: String,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct KnotMigrateArgs {
+
/// Repo to migrate: <owner>/<name> (owner defaults to your handle)
+
#[arg(long)]
+
pub repo: String,
+
/// Target knot hostname (e.g. knot1.tangled.sh)
+
#[arg(long, value_name = "HOST")]
+
pub to: String,
+
/// Use HTTPS source when seeding new repo
+
#[arg(long, default_value_t = true)]
+
pub https: bool,
+
/// Update PDS record knot field after seeding
+
#[arg(long, default_value_t = true)]
+
pub update_record: bool,
}
#[derive(Subcommand, Debug, Clone)]
+190 -1
crates/tangled-cli/src/commands/knot.rs
···
-
use crate::cli::{Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotRefArgs, KnotVerifyArgs};
+
use crate::cli::{
+
Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotMigrateArgs, KnotRefArgs, KnotVerifyArgs,
+
};
+
use anyhow::anyhow;
use anyhow::Result;
+
use git2::{Direction, Repository as GitRepository, StatusOptions};
+
use std::path::Path;
+
use tangled_config::session::SessionManager;
pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
match cmd {
···
KnotCommand::Verify(args) => verify(args).await,
KnotCommand::SetDefault(args) => set_default(args).await,
KnotCommand::Remove(args) => remove(args).await,
+
KnotCommand::Migrate(args) => migrate(args).await,
}
}
···
println!("Knot remove (stub) url={}", args.url);
Ok(())
}
+
+
async fn migrate(args: KnotMigrateArgs) -> Result<()> {
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
// 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 pds_client = tangled_api::TangledClient::new(&pds);
+
let info = pds_client
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
+
.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 client = tangled_api::TangledClient::default();
+
let opts = tangled_api::client::CreateRepoOptions {
+
did: &session.did,
+
name: &name,
+
knot: &args.to,
+
description: info.description.as_deref(),
+
default_branch: None,
+
source: Some(&source),
+
pds_base: &pds,
+
access_jwt: &session.access_jwt,
+
};
+
client.create_repo(opts).await?;
+
+
// Update the PDS record to point to the new knot
+
if args.update_record {
+
client
+
.update_repo_knot(
+
&session.did,
+
&info.rkey,
+
&args.to,
+
&pds,
+
&session.access_jwt,
+
)
+
.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
+
}
+193 -12
crates/tangled-cli/src/commands/repo.rs
···
use anyhow::{anyhow, Result};
+
use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks};
use serde_json;
+
use std::path::PathBuf;
use tangled_config::session::SessionManager;
use crate::cli::{
···
}
async fn clone(args: RepoCloneArgs) -> Result<()> {
-
println!(
-
"Cloning repo '{}' (stub) https={} depth={:?}",
-
args.repo, args.https, args.depth
-
);
-
Ok(())
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
+
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 pds_client = tangled_api::TangledClient::new(&pds);
+
let info = pds_client
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
+
.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 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 mut fetch_opts = FetchOptions::new();
+
fetch_opts.remote_callbacks(callbacks);
+
if let Some(d) = args.depth {
+
fetch_opts.depth(d as i32);
+
}
+
let mut builder = RepoBuilder::new();
+
builder.fetch_options(fetch_opts);
+
match builder.clone(&remote, &target) {
+
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()))
+
}
+
}
}
async fn info(args: RepoInfoArgs) -> Result<()> {
-
println!(
-
"Repository info '{}' (stub) stats={} contributors={}",
-
args.repo, args.stats, args.contributors
-
);
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
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 pds_client = tangled_api::TangledClient::new(&pds);
+
let info = pds_client
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
+
.await?;
+
+
println!("NAME: {}", info.name);
+
println!("OWNER DID: {}", info.did);
+
println!("KNOT: {}", info.knot);
+
if let Some(desc) = info.description.as_deref() {
+
if !desc.is_empty() {
+
println!("DESCRIPTION: {}", desc);
+
}
+
}
+
+
let knot_host = if info.knot == "knot1.tangled.sh" {
+
"tangled.org".to_string()
+
} else {
+
info.knot.clone()
+
};
+
if args.stats {
+
let client = tangled_api::TangledClient::default();
+
if let Ok(def) = client
+
.get_default_branch(&knot_host, &info.did, &info.name)
+
.await
+
{
+
println!(
+
"DEFAULT BRANCH: {} ({})",
+
def.name,
+
def.short_hash.unwrap_or(def.hash)
+
);
+
if let Some(msg) = def.message {
+
if !msg.is_empty() {
+
println!("LAST COMMIT: {}", msg);
+
}
+
}
+
}
+
if let Ok(langs) = client
+
.get_languages(&knot_host, &info.did, &info.name)
+
.await
+
{
+
if !langs.languages.is_empty() {
+
println!("LANGUAGES:");
+
for l in langs.languages.iter().take(6) {
+
println!(" - {} ({}%)", l.name, l.percentage);
+
}
+
}
+
}
+
}
+
+
if args.contributors {
+
println!("Contributors: not implemented yet");
+
}
Ok(())
}
async fn delete(args: RepoDeleteArgs) -> Result<()> {
-
println!("Deleting repo '{}' (stub) force={}", args.repo, args.force);
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
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 pds_client = tangled_api::TangledClient::new(&pds);
+
let record = pds_client
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
+
.await?;
+
let did = record.did;
+
let api = tangled_api::TangledClient::default();
+
api.delete_repo(&did, &name, &pds, &session.access_jwt)
+
.await?;
+
println!("Deleted repo '{}'", name);
Ok(())
}
async fn star(args: RepoRefArgs) -> Result<()> {
-
println!("Starring repo '{}' (stub)", args.repo);
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
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 pds_client = tangled_api::TangledClient::new(&pds);
+
let info = pds_client
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
+
.await?;
+
let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
+
let api = tangled_api::TangledClient::default();
+
api.star_repo(&pds, &session.access_jwt, &subject, &session.did)
+
.await?;
+
println!("Starred {}/{}", owner, name);
Ok(())
}
async fn unstar(args: RepoRefArgs) -> Result<()> {
-
println!("Unstarring repo '{}' (stub)", args.repo);
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
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 pds_client = tangled_api::TangledClient::new(&pds);
+
let info = pds_client
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
+
.await?;
+
let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
+
let api = tangled_api::TangledClient::default();
+
api.unstar_repo(&pds, &session.access_jwt, &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())
+
}
+
}