+2
Cargo.lock
+2
Cargo.lock
+396
-1
crates/tangled-api/src/client.rs
+396
-1
crates/tangled-api/src/client.rs
···
152
152
153
153
#[derive(Deserialize)]
154
154
struct RecordItem {
155
+
uri: String,
155
156
value: Repository,
156
157
}
157
158
#[derive(Deserialize)]
···
169
170
let res: ListRes = self
170
171
.get_json("com.atproto.repo.listRecords", ¶ms, bearer)
171
172
.await?;
172
-
let mut repos: Vec<Repository> = res.records.into_iter().map(|r| r.value).collect();
173
+
let mut repos: Vec<Repository> = res
174
+
.records
175
+
.into_iter()
176
+
.map(|r| {
177
+
let mut val = r.value;
178
+
if val.rkey.is_none() {
179
+
if let Some(k) = Self::uri_rkey(&r.uri) {
180
+
val.rkey = Some(k);
181
+
}
182
+
}
183
+
if val.did.is_none() {
184
+
if let Some(d) = Self::uri_did(&r.uri) {
185
+
val.did = Some(d);
186
+
}
187
+
}
188
+
val
189
+
})
190
+
.collect();
173
191
// Apply optional filters client-side
174
192
if let Some(k) = knot {
175
193
repos.retain(|r| r.knot.as_deref().unwrap_or("") == k);
···
277
295
let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?;
278
296
Ok(())
279
297
}
298
+
299
+
pub async fn get_repo_info(
300
+
&self,
301
+
owner: &str,
302
+
name: &str,
303
+
bearer: Option<&str>,
304
+
) -> Result<RepoRecord> {
305
+
let did = if owner.starts_with("did:") {
306
+
owner.to_string()
307
+
} else {
308
+
#[derive(Deserialize)]
309
+
struct Res {
310
+
did: String,
311
+
}
312
+
let params = [("handle", owner.to_string())];
313
+
let res: Res = self
314
+
.get_json("com.atproto.identity.resolveHandle", ¶ms, bearer)
315
+
.await?;
316
+
res.did
317
+
};
318
+
319
+
#[derive(Deserialize)]
320
+
struct RecordItem {
321
+
uri: String,
322
+
value: Repository,
323
+
}
324
+
#[derive(Deserialize)]
325
+
struct ListRes {
326
+
#[serde(default)]
327
+
records: Vec<RecordItem>,
328
+
}
329
+
let params = vec![
330
+
("repo", did.clone()),
331
+
("collection", "sh.tangled.repo".to_string()),
332
+
("limit", "100".to_string()),
333
+
];
334
+
let res: ListRes = self
335
+
.get_json("com.atproto.repo.listRecords", ¶ms, bearer)
336
+
.await?;
337
+
for item in res.records {
338
+
if item.value.name == name {
339
+
let rkey =
340
+
Self::uri_rkey(&item.uri).ok_or_else(|| anyhow!("missing rkey in uri"))?;
341
+
let knot = item.value.knot.unwrap_or_default();
342
+
return Ok(RepoRecord {
343
+
did: did.clone(),
344
+
name: name.to_string(),
345
+
rkey,
346
+
knot,
347
+
description: item.value.description,
348
+
});
349
+
}
350
+
}
351
+
Err(anyhow!("repo not found for owner/name"))
352
+
}
353
+
354
+
pub async fn delete_repo(
355
+
&self,
356
+
did: &str,
357
+
name: &str,
358
+
pds_base: &str,
359
+
access_jwt: &str,
360
+
) -> Result<()> {
361
+
let pds_client = TangledClient::new(pds_base);
362
+
let info = pds_client
363
+
.get_repo_info(did, name, Some(access_jwt))
364
+
.await?;
365
+
366
+
#[derive(Serialize)]
367
+
struct DeleteRecordReq<'a> {
368
+
repo: &'a str,
369
+
collection: &'a str,
370
+
rkey: &'a str,
371
+
}
372
+
let del = DeleteRecordReq {
373
+
repo: did,
374
+
collection: "sh.tangled.repo",
375
+
rkey: &info.rkey,
376
+
};
377
+
let _: serde_json::Value = pds_client
378
+
.post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt))
379
+
.await?;
380
+
381
+
let host = self
382
+
.base_url
383
+
.trim_end_matches('/')
384
+
.strip_prefix("https://")
385
+
.or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://"))
386
+
.ok_or_else(|| anyhow!("invalid base_url"))?;
387
+
let audience = format!("did:web:{}", host);
388
+
#[derive(Deserialize)]
389
+
struct GetSARes {
390
+
token: String,
391
+
}
392
+
let params = [
393
+
("aud", audience),
394
+
("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
395
+
];
396
+
let sa: GetSARes = pds_client
397
+
.get_json(
398
+
"com.atproto.server.getServiceAuth",
399
+
¶ms,
400
+
Some(access_jwt),
401
+
)
402
+
.await?;
403
+
404
+
#[derive(Serialize)]
405
+
struct DeleteReq<'a> {
406
+
did: &'a str,
407
+
name: &'a str,
408
+
rkey: &'a str,
409
+
}
410
+
let body = DeleteReq {
411
+
did,
412
+
name,
413
+
rkey: &info.rkey,
414
+
};
415
+
let _: serde_json::Value = self
416
+
.post_json("sh.tangled.repo.delete", &body, Some(&sa.token))
417
+
.await?;
418
+
Ok(())
419
+
}
420
+
421
+
pub async fn update_repo_knot(
422
+
&self,
423
+
did: &str,
424
+
rkey: &str,
425
+
new_knot: &str,
426
+
pds_base: &str,
427
+
access_jwt: &str,
428
+
) -> Result<()> {
429
+
let pds_client = TangledClient::new(pds_base);
430
+
#[derive(Deserialize, Serialize, Clone)]
431
+
struct Rec {
432
+
name: String,
433
+
knot: String,
434
+
#[serde(skip_serializing_if = "Option::is_none")]
435
+
description: Option<String>,
436
+
#[serde(rename = "createdAt")]
437
+
created_at: String,
438
+
}
439
+
#[derive(Deserialize)]
440
+
struct GetRes {
441
+
value: Rec,
442
+
}
443
+
let params = [
444
+
("repo", did.to_string()),
445
+
("collection", "sh.tangled.repo".to_string()),
446
+
("rkey", rkey.to_string()),
447
+
];
448
+
let got: GetRes = pds_client
449
+
.get_json("com.atproto.repo.getRecord", ¶ms, Some(access_jwt))
450
+
.await?;
451
+
let mut rec = got.value;
452
+
rec.knot = new_knot.to_string();
453
+
#[derive(Serialize)]
454
+
struct PutReq<'a> {
455
+
repo: &'a str,
456
+
collection: &'a str,
457
+
rkey: &'a str,
458
+
validate: bool,
459
+
record: Rec,
460
+
}
461
+
let req = PutReq {
462
+
repo: did,
463
+
collection: "sh.tangled.repo",
464
+
rkey,
465
+
validate: true,
466
+
record: rec,
467
+
};
468
+
let _: serde_json::Value = pds_client
469
+
.post_json("com.atproto.repo.putRecord", &req, Some(access_jwt))
470
+
.await?;
471
+
Ok(())
472
+
}
473
+
474
+
pub async fn get_default_branch(
475
+
&self,
476
+
knot_host: &str,
477
+
did: &str,
478
+
name: &str,
479
+
) -> Result<DefaultBranch> {
480
+
#[derive(Deserialize)]
481
+
struct Res {
482
+
name: String,
483
+
hash: String,
484
+
#[serde(rename = "shortHash")]
485
+
short_hash: Option<String>,
486
+
when: String,
487
+
message: Option<String>,
488
+
}
489
+
let knot_client = TangledClient::new(knot_host);
490
+
let repo_param = format!("{}/{}", did, name);
491
+
let params = [("repo", repo_param)];
492
+
let res: Res = knot_client
493
+
.get_json("sh.tangled.repo.getDefaultBranch", ¶ms, None)
494
+
.await?;
495
+
Ok(DefaultBranch {
496
+
name: res.name,
497
+
hash: res.hash,
498
+
short_hash: res.short_hash,
499
+
when: res.when,
500
+
message: res.message,
501
+
})
502
+
}
503
+
504
+
pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> {
505
+
let knot_client = TangledClient::new(knot_host);
506
+
let repo_param = format!("{}/{}", did, name);
507
+
let params = [("repo", repo_param)];
508
+
let res: serde_json::Value = knot_client
509
+
.get_json("sh.tangled.repo.languages", ¶ms, None)
510
+
.await?;
511
+
let langs = res
512
+
.get("languages")
513
+
.cloned()
514
+
.unwrap_or(serde_json::json!([]));
515
+
let languages: Vec<Language> = serde_json::from_value(langs)?;
516
+
let total_size = res.get("totalSize").and_then(|v| v.as_u64());
517
+
let total_files = res.get("totalFiles").and_then(|v| v.as_u64());
518
+
Ok(Languages {
519
+
languages,
520
+
total_size,
521
+
total_files,
522
+
})
523
+
}
524
+
525
+
pub async fn star_repo(
526
+
&self,
527
+
pds_base: &str,
528
+
access_jwt: &str,
529
+
subject_at_uri: &str,
530
+
user_did: &str,
531
+
) -> Result<String> {
532
+
#[derive(Serialize)]
533
+
struct Rec<'a> {
534
+
subject: &'a str,
535
+
#[serde(rename = "createdAt")]
536
+
created_at: String,
537
+
}
538
+
#[derive(Serialize)]
539
+
struct Req<'a> {
540
+
repo: &'a str,
541
+
collection: &'a str,
542
+
validate: bool,
543
+
record: Rec<'a>,
544
+
}
545
+
#[derive(Deserialize)]
546
+
struct Res {
547
+
uri: String,
548
+
}
549
+
let now = chrono::Utc::now().to_rfc3339();
550
+
let rec = Rec {
551
+
subject: subject_at_uri,
552
+
created_at: now,
553
+
};
554
+
let req = Req {
555
+
repo: user_did,
556
+
collection: "sh.tangled.feed.star",
557
+
validate: true,
558
+
record: rec,
559
+
};
560
+
let pds_client = TangledClient::new(pds_base);
561
+
let res: Res = pds_client
562
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
563
+
.await?;
564
+
let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?;
565
+
Ok(rkey)
566
+
}
567
+
568
+
pub async fn unstar_repo(
569
+
&self,
570
+
pds_base: &str,
571
+
access_jwt: &str,
572
+
subject_at_uri: &str,
573
+
user_did: &str,
574
+
) -> Result<()> {
575
+
#[derive(Deserialize)]
576
+
struct Item {
577
+
uri: String,
578
+
value: StarRecord,
579
+
}
580
+
#[derive(Deserialize)]
581
+
struct ListRes {
582
+
#[serde(default)]
583
+
records: Vec<Item>,
584
+
}
585
+
let pds_client = TangledClient::new(pds_base);
586
+
let params = vec![
587
+
("repo", user_did.to_string()),
588
+
("collection", "sh.tangled.feed.star".to_string()),
589
+
("limit", "100".to_string()),
590
+
];
591
+
let res: ListRes = pds_client
592
+
.get_json("com.atproto.repo.listRecords", ¶ms, Some(access_jwt))
593
+
.await?;
594
+
let mut rkey = None;
595
+
for item in res.records {
596
+
if item.value.subject == subject_at_uri {
597
+
rkey = Self::uri_rkey(&item.uri);
598
+
if rkey.is_some() {
599
+
break;
600
+
}
601
+
}
602
+
}
603
+
let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?;
604
+
#[derive(Serialize)]
605
+
struct Del<'a> {
606
+
repo: &'a str,
607
+
collection: &'a str,
608
+
rkey: &'a str,
609
+
}
610
+
let del = Del {
611
+
repo: user_did,
612
+
collection: "sh.tangled.feed.star",
613
+
rkey: &rkey,
614
+
};
615
+
let _: serde_json::Value = pds_client
616
+
.post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt))
617
+
.await?;
618
+
Ok(())
619
+
}
620
+
621
+
fn uri_rkey(uri: &str) -> Option<String> {
622
+
uri.rsplit('/').next().map(|s| s.to_string())
623
+
}
624
+
fn uri_did(uri: &str) -> Option<String> {
625
+
let parts: Vec<&str> = uri.split('/').collect();
626
+
if parts.len() >= 3 {
627
+
Some(parts[2].to_string())
628
+
} else {
629
+
None
630
+
}
631
+
}
280
632
}
281
633
282
634
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
···
288
640
pub description: Option<String>,
289
641
#[serde(default)]
290
642
pub private: bool,
643
+
}
644
+
645
+
#[derive(Debug, Clone)]
646
+
pub struct RepoRecord {
647
+
pub did: String,
648
+
pub name: String,
649
+
pub rkey: String,
650
+
pub knot: String,
651
+
pub description: Option<String>,
652
+
}
653
+
654
+
#[derive(Debug, Clone, Serialize, Deserialize)]
655
+
pub struct DefaultBranch {
656
+
pub name: String,
657
+
pub hash: String,
658
+
#[serde(skip_serializing_if = "Option::is_none")]
659
+
pub short_hash: Option<String>,
660
+
pub when: String,
661
+
#[serde(skip_serializing_if = "Option::is_none")]
662
+
pub message: Option<String>,
663
+
}
664
+
665
+
#[derive(Debug, Clone, Serialize, Deserialize)]
666
+
pub struct Language {
667
+
pub name: String,
668
+
pub size: u64,
669
+
pub percentage: u64,
670
+
}
671
+
672
+
#[derive(Debug, Clone, Serialize, Deserialize)]
673
+
pub struct Languages {
674
+
pub languages: Vec<Language>,
675
+
#[serde(skip_serializing_if = "Option::is_none")]
676
+
pub total_size: Option<u64>,
677
+
#[serde(skip_serializing_if = "Option::is_none")]
678
+
pub total_files: Option<u64>,
679
+
}
680
+
681
+
#[derive(Debug, Clone, Serialize, Deserialize)]
682
+
pub struct StarRecord {
683
+
pub subject: String,
684
+
#[serde(rename = "createdAt")]
685
+
pub created_at: String,
291
686
}
292
687
293
688
#[derive(Debug, Clone)]
+2
-1
crates/tangled-cli/Cargo.toml
+2
-1
crates/tangled-cli/Cargo.toml
···
14
14
serde = { workspace = true, features = ["derive"] }
15
15
serde_json = { workspace = true }
16
16
tokio = { workspace = true, features = ["full"] }
17
+
git2 = { workspace = true }
18
+
url = { workspace = true }
17
19
18
20
# Internal crates
19
21
tangled-config = { path = "../tangled-config" }
20
22
tangled-api = { path = "../tangled-api" }
21
23
tangled-git = { path = "../tangled-git" }
22
-
+18
crates/tangled-cli/src/cli.rs
+18
crates/tangled-cli/src/cli.rs
···
299
299
Verify(KnotVerifyArgs),
300
300
SetDefault(KnotRefArgs),
301
301
Remove(KnotRefArgs),
302
+
/// Migrate a repository to another knot
303
+
Migrate(KnotMigrateArgs),
302
304
}
303
305
304
306
#[derive(Args, Debug, Clone)]
···
328
330
#[derive(Args, Debug, Clone)]
329
331
pub struct KnotRefArgs {
330
332
pub url: String,
333
+
}
334
+
335
+
#[derive(Args, Debug, Clone)]
336
+
pub struct KnotMigrateArgs {
337
+
/// Repo to migrate: <owner>/<name> (owner defaults to your handle)
338
+
#[arg(long)]
339
+
pub repo: String,
340
+
/// Target knot hostname (e.g. knot1.tangled.sh)
341
+
#[arg(long, value_name = "HOST")]
342
+
pub to: String,
343
+
/// Use HTTPS source when seeding new repo
344
+
#[arg(long, default_value_t = true)]
345
+
pub https: bool,
346
+
/// Update PDS record knot field after seeding
347
+
#[arg(long, default_value_t = true)]
348
+
pub update_record: bool,
331
349
}
332
350
333
351
#[derive(Subcommand, Debug, Clone)]
+190
-1
crates/tangled-cli/src/commands/knot.rs
+190
-1
crates/tangled-cli/src/commands/knot.rs
···
1
-
use crate::cli::{Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotRefArgs, KnotVerifyArgs};
1
+
use crate::cli::{
2
+
Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotMigrateArgs, KnotRefArgs, KnotVerifyArgs,
3
+
};
4
+
use anyhow::anyhow;
2
5
use anyhow::Result;
6
+
use git2::{Direction, Repository as GitRepository, StatusOptions};
7
+
use std::path::Path;
8
+
use tangled_config::session::SessionManager;
3
9
4
10
pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
5
11
match cmd {
···
8
14
KnotCommand::Verify(args) => verify(args).await,
9
15
KnotCommand::SetDefault(args) => set_default(args).await,
10
16
KnotCommand::Remove(args) => remove(args).await,
17
+
KnotCommand::Migrate(args) => migrate(args).await,
11
18
}
12
19
}
13
20
···
41
48
println!("Knot remove (stub) url={}", args.url);
42
49
Ok(())
43
50
}
51
+
52
+
async fn migrate(args: KnotMigrateArgs) -> Result<()> {
53
+
let mgr = SessionManager::default();
54
+
let session = mgr
55
+
.load()?
56
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
57
+
// 1) Ensure we're inside a git repository and working tree is clean
58
+
let repo = GitRepository::discover(Path::new("."))?;
59
+
let mut status_opts = StatusOptions::new();
60
+
status_opts.include_untracked(false).include_ignored(false);
61
+
let statuses = repo.statuses(Some(&mut status_opts))?;
62
+
if !statuses.is_empty() {
63
+
return Err(anyhow!(
64
+
"working tree has uncommitted changes; commit/push before migrating"
65
+
));
66
+
}
67
+
68
+
// 2) Derive current branch and ensure it's pushed to origin
69
+
let head = match repo.head() {
70
+
Ok(h) => h,
71
+
Err(_) => return Err(anyhow!("repository does not have a HEAD")),
72
+
};
73
+
let head_oid = head
74
+
.target()
75
+
.ok_or_else(|| anyhow!("failed to resolve HEAD OID"))?;
76
+
let head_name = head.shorthand().unwrap_or("");
77
+
let full_ref = head.name().unwrap_or("").to_string();
78
+
if !full_ref.starts_with("refs/heads/") {
79
+
return Err(anyhow!(
80
+
"HEAD is detached; please checkout a branch before migrating"
81
+
));
82
+
}
83
+
let branch = head_name.to_string();
84
+
85
+
let origin = repo.find_remote("origin").or_else(|_| {
86
+
repo.remotes().and_then(|rems| {
87
+
rems.get(0)
88
+
.ok_or(git2::Error::from_str("no remotes configured"))
89
+
.and_then(|name| repo.find_remote(name))
90
+
})
91
+
})?;
92
+
93
+
// Connect and list remote heads to find refs/heads/<branch>
94
+
let mut remote = origin;
95
+
remote.connect(Direction::Fetch)?;
96
+
let remote_heads = remote.list()?;
97
+
let remote_oid = remote_heads
98
+
.iter()
99
+
.find_map(|h| {
100
+
if h.name() == format!("refs/heads/{}", branch) {
101
+
Some(h.oid())
102
+
} else {
103
+
None
104
+
}
105
+
})
106
+
.ok_or_else(|| anyhow!("origin does not have branch '{}' — push first", branch))?;
107
+
if remote_oid != head_oid {
108
+
return Err(anyhow!(
109
+
"local {} ({}) != origin {} ({}); please push before migrating",
110
+
branch,
111
+
head_oid,
112
+
branch,
113
+
remote_oid
114
+
));
115
+
}
116
+
117
+
// 3) Parse origin URL to verify repo identity
118
+
let origin_url = remote
119
+
.url()
120
+
.ok_or_else(|| anyhow!("origin has no URL"))?
121
+
.to_string();
122
+
let (origin_owner, origin_name, _origin_host) = parse_remote_url(&origin_url)
123
+
.ok_or_else(|| anyhow!("unsupported origin URL: {}", origin_url))?;
124
+
125
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
126
+
if origin_owner.trim_start_matches('@') != owner.trim_start_matches('@') || origin_name != name
127
+
{
128
+
return Err(anyhow!(
129
+
"repo mismatch: current checkout '{}'/{} != argument '{}'/{}",
130
+
origin_owner,
131
+
origin_name,
132
+
owner,
133
+
name
134
+
));
135
+
}
136
+
137
+
let pds = session
138
+
.pds
139
+
.clone()
140
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
141
+
.unwrap_or_else(|| "https://bsky.social".into());
142
+
let pds_client = tangled_api::TangledClient::new(&pds);
143
+
let info = pds_client
144
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
145
+
.await?;
146
+
147
+
// Build a publicly accessible source URL on tangled.org for the existing repo
148
+
let owner_path = if owner.starts_with('@') {
149
+
owner.to_string()
150
+
} else {
151
+
format!("@{}", owner)
152
+
};
153
+
let source = if args.https {
154
+
format!("https://tangled.org/{}/{}", owner_path, name)
155
+
} else {
156
+
format!(
157
+
"git@{}:{}/{}",
158
+
info.knot,
159
+
owner.trim_start_matches('@'),
160
+
name
161
+
)
162
+
};
163
+
164
+
// Create the repo on the target knot, seeding from source
165
+
let client = tangled_api::TangledClient::default();
166
+
let opts = tangled_api::client::CreateRepoOptions {
167
+
did: &session.did,
168
+
name: &name,
169
+
knot: &args.to,
170
+
description: info.description.as_deref(),
171
+
default_branch: None,
172
+
source: Some(&source),
173
+
pds_base: &pds,
174
+
access_jwt: &session.access_jwt,
175
+
};
176
+
client.create_repo(opts).await?;
177
+
178
+
// Update the PDS record to point to the new knot
179
+
if args.update_record {
180
+
client
181
+
.update_repo_knot(
182
+
&session.did,
183
+
&info.rkey,
184
+
&args.to,
185
+
&pds,
186
+
&session.access_jwt,
187
+
)
188
+
.await?;
189
+
}
190
+
191
+
println!("Migrated repo '{}' to knot {}", name, args.to);
192
+
println!(
193
+
"Note: old repository on {} is not deleted automatically.",
194
+
info.knot
195
+
);
196
+
Ok(())
197
+
}
198
+
199
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) {
200
+
if let Some((owner, name)) = spec.split_once('/') {
201
+
(owner, name.to_string())
202
+
} else {
203
+
(default_owner, spec.to_string())
204
+
}
205
+
}
206
+
207
+
fn parse_remote_url(url: &str) -> Option<(String, String, String)> {
208
+
// Returns (owner, name, host)
209
+
if let Some(rest) = url.strip_prefix("git@") {
210
+
// git@host:owner/name(.git)
211
+
let mut parts = rest.split(':');
212
+
let host = parts.next()?.to_string();
213
+
let path = parts.next()?;
214
+
let mut segs = path.trim_end_matches(".git").split('/');
215
+
let owner = segs.next()?.to_string();
216
+
let name = segs.next()?.to_string();
217
+
return Some((owner, name, host));
218
+
}
219
+
if url.starts_with("http://") || url.starts_with("https://") {
220
+
if let Ok(parsed) = url::Url::parse(url) {
221
+
let host = parsed.host_str().unwrap_or("").to_string();
222
+
let path = parsed.path().trim_matches('/');
223
+
// paths may be like '@owner/name' or 'owner/name'
224
+
let mut segs = path.trim_end_matches(".git").split('/');
225
+
let first = segs.next()?;
226
+
let owner = first.trim_start_matches('@').to_string();
227
+
let name = segs.next()?.to_string();
228
+
return Some((owner, name, host));
229
+
}
230
+
}
231
+
None
232
+
}
+193
-12
crates/tangled-cli/src/commands/repo.rs
+193
-12
crates/tangled-cli/src/commands/repo.rs
···
1
1
use anyhow::{anyhow, Result};
2
+
use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks};
2
3
use serde_json;
4
+
use std::path::PathBuf;
3
5
use tangled_config::session::SessionManager;
4
6
5
7
use crate::cli::{
···
95
97
}
96
98
97
99
async fn clone(args: RepoCloneArgs) -> Result<()> {
98
-
println!(
99
-
"Cloning repo '{}' (stub) https={} depth={:?}",
100
-
args.repo, args.https, args.depth
101
-
);
102
-
Ok(())
100
+
let mgr = SessionManager::default();
101
+
let session = mgr
102
+
.load()?
103
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
104
+
105
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
106
+
let pds = session
107
+
.pds
108
+
.clone()
109
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
110
+
.unwrap_or_else(|| "https://bsky.social".into());
111
+
let pds_client = tangled_api::TangledClient::new(&pds);
112
+
let info = pds_client
113
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
114
+
.await?;
115
+
116
+
let remote = if args.https {
117
+
let owner_path = if owner.starts_with('@') {
118
+
owner.to_string()
119
+
} else {
120
+
format!("@{}", owner)
121
+
};
122
+
format!("https://tangled.org/{}/{}", owner_path, name)
123
+
} else {
124
+
let knot = if info.knot == "knot1.tangled.sh" {
125
+
"tangled.org".to_string()
126
+
} else {
127
+
info.knot.clone()
128
+
};
129
+
format!("git@{}:{}/{}", knot, owner.trim_start_matches('@'), name)
130
+
};
131
+
132
+
let target = PathBuf::from(&name);
133
+
println!("Cloning {} -> {:?}", remote, target);
134
+
135
+
let mut callbacks = RemoteCallbacks::new();
136
+
callbacks.credentials(|_url, username_from_url, _allowed| {
137
+
if let Some(user) = username_from_url {
138
+
Cred::ssh_key_from_agent(user)
139
+
} else {
140
+
Cred::default()
141
+
}
142
+
});
143
+
let mut fetch_opts = FetchOptions::new();
144
+
fetch_opts.remote_callbacks(callbacks);
145
+
if let Some(d) = args.depth {
146
+
fetch_opts.depth(d as i32);
147
+
}
148
+
let mut builder = RepoBuilder::new();
149
+
builder.fetch_options(fetch_opts);
150
+
match builder.clone(&remote, &target) {
151
+
Ok(_) => Ok(()),
152
+
Err(e) => {
153
+
println!("Failed to clone via libgit2: {}", e);
154
+
println!(
155
+
"Hint: try: git clone{} {}",
156
+
args.depth
157
+
.map(|d| format!(" --depth {}", d))
158
+
.unwrap_or_default(),
159
+
remote
160
+
);
161
+
Err(anyhow!(e.to_string()))
162
+
}
163
+
}
103
164
}
104
165
105
166
async fn info(args: RepoInfoArgs) -> Result<()> {
106
-
println!(
107
-
"Repository info '{}' (stub) stats={} contributors={}",
108
-
args.repo, args.stats, args.contributors
109
-
);
167
+
let mgr = SessionManager::default();
168
+
let session = mgr
169
+
.load()?
170
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
171
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
172
+
let pds = session
173
+
.pds
174
+
.clone()
175
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
176
+
.unwrap_or_else(|| "https://bsky.social".into());
177
+
let pds_client = tangled_api::TangledClient::new(&pds);
178
+
let info = pds_client
179
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
180
+
.await?;
181
+
182
+
println!("NAME: {}", info.name);
183
+
println!("OWNER DID: {}", info.did);
184
+
println!("KNOT: {}", info.knot);
185
+
if let Some(desc) = info.description.as_deref() {
186
+
if !desc.is_empty() {
187
+
println!("DESCRIPTION: {}", desc);
188
+
}
189
+
}
190
+
191
+
let knot_host = if info.knot == "knot1.tangled.sh" {
192
+
"tangled.org".to_string()
193
+
} else {
194
+
info.knot.clone()
195
+
};
196
+
if args.stats {
197
+
let client = tangled_api::TangledClient::default();
198
+
if let Ok(def) = client
199
+
.get_default_branch(&knot_host, &info.did, &info.name)
200
+
.await
201
+
{
202
+
println!(
203
+
"DEFAULT BRANCH: {} ({})",
204
+
def.name,
205
+
def.short_hash.unwrap_or(def.hash)
206
+
);
207
+
if let Some(msg) = def.message {
208
+
if !msg.is_empty() {
209
+
println!("LAST COMMIT: {}", msg);
210
+
}
211
+
}
212
+
}
213
+
if let Ok(langs) = client
214
+
.get_languages(&knot_host, &info.did, &info.name)
215
+
.await
216
+
{
217
+
if !langs.languages.is_empty() {
218
+
println!("LANGUAGES:");
219
+
for l in langs.languages.iter().take(6) {
220
+
println!(" - {} ({}%)", l.name, l.percentage);
221
+
}
222
+
}
223
+
}
224
+
}
225
+
226
+
if args.contributors {
227
+
println!("Contributors: not implemented yet");
228
+
}
110
229
Ok(())
111
230
}
112
231
113
232
async fn delete(args: RepoDeleteArgs) -> Result<()> {
114
-
println!("Deleting repo '{}' (stub) force={}", args.repo, args.force);
233
+
let mgr = SessionManager::default();
234
+
let session = mgr
235
+
.load()?
236
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
237
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
238
+
let pds = session
239
+
.pds
240
+
.clone()
241
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
242
+
.unwrap_or_else(|| "https://bsky.social".into());
243
+
let pds_client = tangled_api::TangledClient::new(&pds);
244
+
let record = pds_client
245
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
246
+
.await?;
247
+
let did = record.did;
248
+
let api = tangled_api::TangledClient::default();
249
+
api.delete_repo(&did, &name, &pds, &session.access_jwt)
250
+
.await?;
251
+
println!("Deleted repo '{}'", name);
115
252
Ok(())
116
253
}
117
254
118
255
async fn star(args: RepoRefArgs) -> Result<()> {
119
-
println!("Starring repo '{}' (stub)", args.repo);
256
+
let mgr = SessionManager::default();
257
+
let session = mgr
258
+
.load()?
259
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
260
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
261
+
let pds = session
262
+
.pds
263
+
.clone()
264
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
265
+
.unwrap_or_else(|| "https://bsky.social".into());
266
+
let pds_client = tangled_api::TangledClient::new(&pds);
267
+
let info = pds_client
268
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
269
+
.await?;
270
+
let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
271
+
let api = tangled_api::TangledClient::default();
272
+
api.star_repo(&pds, &session.access_jwt, &subject, &session.did)
273
+
.await?;
274
+
println!("Starred {}/{}", owner, name);
120
275
Ok(())
121
276
}
122
277
123
278
async fn unstar(args: RepoRefArgs) -> Result<()> {
124
-
println!("Unstarring repo '{}' (stub)", args.repo);
279
+
let mgr = SessionManager::default();
280
+
let session = mgr
281
+
.load()?
282
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
283
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
284
+
let pds = session
285
+
.pds
286
+
.clone()
287
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
288
+
.unwrap_or_else(|| "https://bsky.social".into());
289
+
let pds_client = tangled_api::TangledClient::new(&pds);
290
+
let info = pds_client
291
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
292
+
.await?;
293
+
let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
294
+
let api = tangled_api::TangledClient::default();
295
+
api.unstar_repo(&pds, &session.access_jwt, &subject, &session.did)
296
+
.await?;
297
+
println!("Unstarred {}/{}", owner, name);
125
298
Ok(())
126
299
}
300
+
301
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) {
302
+
if let Some((owner, name)) = spec.split_once('/') {
303
+
(owner, name.to_string())
304
+
} else {
305
+
(default_owner, spec.to_string())
306
+
}
307
+
}