1#![allow(clippy::print_stdout, clippy::print_stderr)]
7
8use crate::cli::RepoCommand;
9use crate::client::Client;
10use crate::output::{format_count, format_date, print_json};
11use crate::repository::{detect_remote, parse_repo_spec};
12use anyhow::Context;
13use std::io::Write;
14
15pub fn run(cmd: RepoCommand) -> anyhow::Result<()> {
21 match cmd {
22 RepoCommand::View {
23 owner_repo,
24 web,
25 json,
26 hostname,
27 } => view(owner_repo, web, json, hostname.as_deref()),
28 RepoCommand::List {
29 owner,
30 visibility,
31 fork,
32 language,
33 limit,
34 json,
35 hostname,
36 } => list(
37 owner.as_deref(),
38 &visibility,
39 &fork,
40 language.as_deref(),
41 limit,
42 json,
43 hostname.as_deref(),
44 ),
45 RepoCommand::Clone {
46 owner_repo,
47 directory,
48 upstream_remote_name,
49 hostname,
50 } => clone(
51 &owner_repo,
52 directory.as_deref(),
53 &upstream_remote_name,
54 hostname.as_deref(),
55 ),
56 RepoCommand::Create {
57 name,
58 description,
59 private,
60 internal,
61 org,
62 template,
63 clone,
64 remote,
65 push,
66 disable_wiki,
67 disable_issues,
68 hostname,
69 } => repo_create(
70 &name,
71 description.as_deref(),
72 private,
73 internal,
74 org.as_deref(),
75 template.as_deref(),
76 clone,
77 &remote,
78 push,
79 disable_wiki,
80 disable_issues,
81 hostname.as_deref(),
82 ),
83 RepoCommand::Fork {
84 owner_repo,
85 org,
86 clone,
87 remote,
88 fork_name,
89 hostname,
90 } => repo_fork(
91 &owner_repo,
92 org.as_deref(),
93 clone,
94 &remote,
95 fork_name.as_deref(),
96 hostname.as_deref(),
97 ),
98 RepoCommand::Delete {
99 owner_repo,
100 yes,
101 hostname,
102 } => repo_delete(&owner_repo, yes, hostname.as_deref()),
103 RepoCommand::Edit {
104 repo,
105 description,
106 visibility,
107 add_topic,
108 remove_topic,
109 default_branch,
110 enable_wiki,
111 enable_issues,
112 enable_projects,
113 hostname,
114 } => repo_edit(
115 repo.as_deref(),
116 description.as_deref(),
117 visibility.as_deref(),
118 &add_topic,
119 &remove_topic,
120 default_branch.as_deref(),
121 enable_wiki.as_deref(),
122 enable_issues.as_deref(),
123 enable_projects.as_deref(),
124 hostname.as_deref(),
125 ),
126 RepoCommand::Transfer {
127 target,
128 repo,
129 new_name,
130 team,
131 yes,
132 hostname,
133 } => repo_transfer(
134 &target,
135 repo.as_deref(),
136 new_name.as_deref(),
137 &team,
138 yes,
139 hostname.as_deref(),
140 ),
141 RepoCommand::Sync {
142 repo,
143 branch,
144 hostname,
145 } => sync(repo.as_deref(), branch.as_deref(), hostname.as_deref()),
146 }
147}
148
149fn view(
158 owner_repo: Option<String>,
159 web: bool,
160 json: Option<Vec<String>>,
161 hostname: Option<&str>,
162) -> anyhow::Result<()> {
163 let spec = match owner_repo {
165 Some(s) => parse_repo_spec(&s).context("invalid repository spec")?,
166 None => detect_remote().ok_or_else(|| {
167 anyhow::anyhow!(
168 "could not detect repository from current directory; specify OWNER/REPO"
169 )
170 })?,
171 };
172
173 let host = hostname.unwrap_or("github.com");
174 let client = Client::new(host).context("failed to create HTTP client")?;
175
176 let path = format!("/repos/{}/{}", spec.owner, spec.repo);
177 let response = client
178 .get(&path)
179 .context("failed to fetch repository data")?;
180
181 let status = response.status();
182 if status == reqwest::StatusCode::NOT_FOUND {
183 anyhow::bail!("repository '{spec}' not found");
184 }
185 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
186 anyhow::bail!("authentication required to view repository '{spec}'");
187 }
188 if !status.is_success() {
189 anyhow::bail!("failed to fetch repository '{spec}': HTTP {status}");
190 }
191
192 let repo: serde_json::Value = response
193 .json()
194 .context("failed to parse repository response")?;
195
196 if web {
198 if let Some(url) = repo["html_url"].as_str() {
199 open_in_browser(url);
200 }
201 return Ok(());
202 }
203
204 if let Some(fields) = json {
206 let fields_ref: Option<&[String]> = if fields.is_empty() {
207 None
208 } else {
209 Some(&fields)
210 };
211 print_json(&repo, fields_ref);
212 return Ok(());
213 }
214
215 print_repo_table(&repo);
217 Ok(())
218}
219
220fn list(
230 owner: Option<&str>,
231 visibility: &str,
232 fork: &str,
233 language: Option<&str>,
234 limit: u32,
235 json: Option<Vec<String>>,
236 hostname: Option<&str>,
237) -> anyhow::Result<()> {
238 let host = hostname.unwrap_or("github.com");
239 let client = Client::new(host).context("failed to create HTTP client")?;
240
241 let path = match owner {
243 Some(owner_name) => {
244 let user_path = format!("/users/{owner_name}/repos");
246 let response = client
247 .get(&user_path)
248 .context("failed to fetch repositories")?;
249 if response.status() == reqwest::StatusCode::NOT_FOUND {
250 format!("/orgs/{owner_name}/repos")
251 } else if !response.status().is_success() {
252 let status = response.status();
253 anyhow::bail!("failed to fetch repositories for '{owner_name}': HTTP {status}");
254 } else {
255 let repos: Vec<serde_json::Value> = response
257 .json()
258 .context("failed to parse repository response")?;
259 let filtered = filter_repos(repos, visibility, fork, language, limit);
260 output_repos(&filtered, json);
261 return Ok(());
262 }
263 }
264 None => "/user/repos".to_string(),
265 };
266
267 let mut query_params = vec![
269 ("per_page", limit.min(100).to_string()),
270 ("type", "all".to_string()),
271 ];
272
273 if visibility != "all" {
275 query_params.push(("visibility", visibility.to_string()));
276 }
277
278 query_params.push(("sort", "updated".to_string()));
280
281 let query_string = query_params
282 .iter()
283 .map(|(k, v)| format!("{k}={v}"))
284 .collect::<Vec<_>>()
285 .join("&");
286
287 let full_path = format!("{path}?{query_string}");
288
289 let response = client
290 .get(&full_path)
291 .context("failed to fetch repositories")?;
292
293 let status = response.status();
294 if status == reqwest::StatusCode::NOT_FOUND {
295 if let Some(owner_name) = owner {
296 anyhow::bail!("user or organization '{owner_name}' not found");
297 }
298 anyhow::bail!("could not fetch repositories");
299 }
300 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
301 anyhow::bail!("authentication required to list repositories");
302 }
303 if !status.is_success() {
304 anyhow::bail!("failed to list repositories: HTTP {status}");
305 }
306
307 let repos: Vec<serde_json::Value> = response
308 .json()
309 .context("failed to parse repository response")?;
310
311 let filtered = filter_repos(repos, visibility, fork, language, limit);
312 output_repos(&filtered, json);
313 Ok(())
314}
315
316fn clone(
327 owner_repo: &str,
328 directory: Option<&str>,
329 upstream_remote_name: &str,
330 hostname: Option<&str>,
331) -> anyhow::Result<()> {
332 let host = hostname.unwrap_or("github.com");
333
334 let (clone_url, _is_fork, parent_clone_url) =
336 if owner_repo.starts_with("https://") || owner_repo.starts_with("git@") {
337 (owner_repo.to_string(), false, None)
339 } else {
340 let spec = parse_repo_spec(owner_repo).context("invalid repository spec")?;
342 let client = Client::new(host).context("failed to create HTTP client")?;
343
344 let path = format!("/repos/{}/{}", spec.owner, spec.repo);
345 let response = client
346 .get(&path)
347 .context("failed to fetch repository data")?;
348
349 let status = response.status();
350 if status == reqwest::StatusCode::NOT_FOUND {
351 anyhow::bail!("repository '{spec}' not found");
352 }
353 if !status.is_success() {
354 anyhow::bail!("failed to fetch repository '{spec}': HTTP {status}");
355 }
356
357 let repo: serde_json::Value = response
358 .json()
359 .context("failed to parse repository response")?;
360
361 let clone_url = repo["clone_url"]
362 .as_str()
363 .ok_or_else(|| anyhow::anyhow!("no clone_url in repository response"))?
364 .to_string();
365
366 let is_fork = repo["fork"].as_bool().unwrap_or(false);
367 let parent_clone_url = if is_fork {
368 repo["parent"]["clone_url"].as_str().map(String::from)
369 } else {
370 None
371 };
372
373 (clone_url, is_fork, parent_clone_url)
374 };
375
376 let dest_dir = directory.map_or_else(
378 || {
379 let repo_name = clone_url.rfind('/').map_or(clone_url.as_str(), |pos| {
381 clone_url[pos + 1..].trim_end_matches(".git")
382 });
383 std::path::PathBuf::from(repo_name)
384 },
385 std::path::PathBuf::from,
386 );
387
388 eprintln!("Cloning into '{}'...", dest_dir.display());
390
391 let mut prepare_fetch =
392 gix::prepare_clone(clone_url.as_str(), &dest_dir).context("failed to prepare clone")?;
393
394 let (mut prepare_checkout, _outcome) = prepare_fetch
395 .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
396 .context("failed to fetch repository")?;
397
398 let (_repo, _checkout_outcome) = prepare_checkout
399 .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
400 .context("failed to checkout worktree")?;
401
402 if let Some(parent_url) = parent_clone_url {
404 eprintln!("Adding upstream remote '{upstream_remote_name}'...");
405
406 let repo = gix::open(&dest_dir).context("failed to open cloned repository")?;
408
409 let config_path = repo.git_dir().join("config");
411 let mut file = std::fs::OpenOptions::new()
412 .append(true)
413 .open(&config_path)
414 .context("failed to open git config for appending")?;
415
416 let url_escaped = parent_url.replace('"', "\\\"");
417 writeln!(
418 file,
419 "[remote \"{upstream_remote_name}\"]\n\turl = {url_escaped}\n\tfetch = +refs/heads/*:refs/remotes/{upstream_remote_name}/*"
420 )
421 .context("failed to write upstream remote config")?;
422
423 eprintln!("Added upstream remote '{upstream_remote_name}'");
424 }
425
426 eprintln!("Clone complete.");
427 Ok(())
428}
429
430#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
440fn repo_create(
441 name: &str,
442 description: Option<&str>,
443 private: bool,
444 internal: bool,
445 org: Option<&str>,
446 _template: Option<&str>,
447 clone: bool,
448 _remote: &str,
449 _push: bool,
450 disable_wiki: bool,
451 disable_issues: bool,
452 hostname: Option<&str>,
453) -> anyhow::Result<()> {
454 let host = hostname.unwrap_or("github.com");
455 let client = Client::new(host).context("failed to create HTTP client")?;
456
457 let mut body_map = serde_json::Map::new();
459 body_map.insert(
460 "name".to_string(),
461 serde_json::Value::String(name.to_string()),
462 );
463 body_map.insert(
464 "private".to_string(),
465 serde_json::Value::Bool(private || internal),
466 );
467 if internal {
468 body_map.insert(
469 "visibility".to_string(),
470 serde_json::Value::String("internal".to_string()),
471 );
472 }
473 if let Some(d) = description {
474 body_map.insert(
475 "description".to_string(),
476 serde_json::Value::String(d.to_string()),
477 );
478 }
479 if disable_wiki {
480 body_map.insert("has_wiki".to_string(), serde_json::Value::Bool(false));
481 }
482 if disable_issues {
483 body_map.insert("has_issues".to_string(), serde_json::Value::Bool(false));
484 }
485
486 let body_value = serde_json::Value::Object(body_map);
487
488 let path = org.map_or_else(|| "/user/repos".to_string(), |o| format!("/orgs/{o}/repos"));
490
491 let response = client
492 .post(&path, &body_value)
493 .context("failed to create repository")?;
494
495 let status = response.status();
496 if !status.is_success() {
497 let err_body: serde_json::Value = response.json().unwrap_or_default();
498 let msg = err_body["message"].as_str().unwrap_or("creation failed");
499 anyhow::bail!("failed to create repository: {msg}");
500 }
501
502 let repo: serde_json::Value = response
503 .json()
504 .context("failed to parse repository response")?;
505
506 let clone_url = repo["clone_url"].as_str().unwrap_or("");
507 let html_url = repo["html_url"].as_str().unwrap_or("");
508 let full_name = repo["full_name"].as_str().unwrap_or("");
509
510 println!("{html_url}");
511
512 if clone && !clone_url.is_empty() {
514 let dest_dir = std::path::PathBuf::from(name);
515 eprintln!("Cloning into '{}'...", dest_dir.display());
516
517 let url =
518 gix::url::parse(clone_url.as_bytes().into()).context("failed to parse clone URL")?;
519 let mut prepare_fetch =
520 gix::prepare_clone(url, &dest_dir).context("failed to prepare clone")?;
521 let (mut prepare_checkout, _outcome) = prepare_fetch
522 .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
523 .context("failed to fetch repository")?;
524 let (_repo, _checkout_outcome) = prepare_checkout
525 .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
526 .context("failed to checkout worktree")?;
527 eprintln!("Cloned {full_name}");
528 }
529
530 Ok(())
531}
532
533fn repo_fork(
542 owner_repo: &str,
543 org: Option<&str>,
544 clone: bool,
545 _remote: &str,
546 fork_name: Option<&str>,
547 hostname: Option<&str>,
548) -> anyhow::Result<()> {
549 let spec = parse_repo_spec(owner_repo).context("invalid repository spec")?;
550 let host = hostname.unwrap_or("github.com");
551 let client = Client::new(host).context("failed to create HTTP client")?;
552
553 let mut body_map = serde_json::Map::new();
555 if let Some(o) = org {
556 body_map.insert(
557 "organization".to_string(),
558 serde_json::Value::String(o.to_string()),
559 );
560 }
561 if let Some(n) = fork_name {
562 body_map.insert("name".to_string(), serde_json::Value::String(n.to_string()));
563 }
564
565 let body_value = serde_json::Value::Object(body_map);
566
567 let path = format!("/repos/{}/{}/forks", spec.owner, spec.repo);
568 let response = client
569 .post(&path, &body_value)
570 .context("failed to fork repository")?;
571
572 let status = response.status();
573 if status == reqwest::StatusCode::NOT_FOUND {
574 anyhow::bail!("repository '{spec}' not found");
575 }
576 if status == reqwest::StatusCode::ACCEPTED {
577 let fork: serde_json::Value = response.json().context("failed to parse fork response")?;
579 let html_url = fork["html_url"].as_str().unwrap_or("");
580 println!("{html_url}");
581 eprintln!("Fork is being created. It may take a few minutes.");
582 return Ok(());
583 }
584 if !status.is_success() {
585 let err_body: serde_json::Value = response.json().unwrap_or_default();
586 let msg = err_body["message"].as_str().unwrap_or("fork failed");
587 anyhow::bail!("failed to fork repository: {msg}");
588 }
589
590 let fork: serde_json::Value = response.json().context("failed to parse fork response")?;
591
592 let clone_url = fork["clone_url"].as_str().unwrap_or("");
593 let html_url = fork["html_url"].as_str().unwrap_or("");
594 let full_name = fork["full_name"].as_str().unwrap_or("");
595
596 println!("{html_url}");
597
598 if clone && !clone_url.is_empty() {
600 let repo_name = fork_name.unwrap_or(&spec.repo);
601 let dest_dir = std::path::PathBuf::from(repo_name);
602 eprintln!("Cloning into '{}'...", dest_dir.display());
603
604 let url =
605 gix::url::parse(clone_url.as_bytes().into()).context("failed to parse clone URL")?;
606 let mut prepare_fetch =
607 gix::prepare_clone(url, &dest_dir).context("failed to prepare clone")?;
608 let (mut prepare_checkout, _outcome) = prepare_fetch
609 .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
610 .context("failed to fetch repository")?;
611 let (_repo, _checkout_outcome) = prepare_checkout
612 .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
613 .context("failed to checkout worktree")?;
614 eprintln!("Cloned {full_name}");
615 }
616
617 Ok(())
618}
619
620fn repo_delete(owner_repo: &str, yes: bool, hostname: Option<&str>) -> anyhow::Result<()> {
628 let spec = parse_repo_spec(owner_repo).context("invalid repository spec")?;
629
630 if !yes {
632 eprintln!("WARNING: This will delete the repository '{spec}' and all its data.");
633 eprint!("Type the repository name '{spec}' to confirm: ");
634 std::io::stdout().flush().ok();
635 let mut input = String::new();
636 std::io::stdin()
637 .read_line(&mut input)
638 .context("failed to read input")?;
639 let input = input.trim();
640 if input != spec.to_string() {
641 anyhow::bail!("confirmation failed: expected '{spec}', got '{input}'");
642 }
643 }
644
645 let host = hostname.unwrap_or("github.com");
646 let client = Client::new(host).context("failed to create HTTP client")?;
647
648 let path = format!("/repos/{}/{}", spec.owner, spec.repo);
649 let response = client
650 .request("DELETE", &path, &[], None)
651 .context("failed to delete repository")?;
652
653 let status = response.status();
654 if status == reqwest::StatusCode::NOT_FOUND {
655 anyhow::bail!("repository '{spec}' not found");
656 }
657 if status == reqwest::StatusCode::FORBIDDEN {
658 anyhow::bail!("you do not have permission to delete '{spec}'");
659 }
660 if !status.is_success() {
661 anyhow::bail!("failed to delete repository '{spec}': HTTP {status}");
662 }
663
664 println!("Deleted repository '{spec}'");
665 Ok(())
666}
667
668#[allow(clippy::too_many_arguments)]
677fn repo_edit(
678 repo: Option<&str>,
679 description: Option<&str>,
680 visibility: Option<&str>,
681 add_topic: &[String],
682 remove_topic: &[String],
683 default_branch: Option<&str>,
684 enable_wiki: Option<&str>,
685 enable_issues: Option<&str>,
686 enable_projects: Option<&str>,
687 hostname: Option<&str>,
688) -> anyhow::Result<()> {
689 let spec = match repo {
690 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
691 None => detect_remote().ok_or_else(|| {
692 anyhow::anyhow!(
693 "could not detect repository from current directory; specify OWNER/REPO with --repo"
694 )
695 })?,
696 };
697
698 let host = hostname.unwrap_or("github.com");
699 let client = Client::new(host).context("failed to create HTTP client")?;
700
701 let mut body_map = serde_json::Map::new();
703
704 if let Some(d) = description {
705 body_map.insert(
706 "description".to_string(),
707 serde_json::Value::String(d.to_string()),
708 );
709 }
710 if let Some(v) = visibility {
711 body_map.insert(
712 "visibility".to_string(),
713 serde_json::Value::String(v.to_string()),
714 );
715 }
716 if let Some(b) = default_branch {
717 body_map.insert(
718 "default_branch".to_string(),
719 serde_json::Value::String(b.to_string()),
720 );
721 }
722 if let Some(w) = enable_wiki {
723 body_map.insert("has_wiki".to_string(), serde_json::Value::Bool(w == "true"));
724 }
725 if let Some(i) = enable_issues {
726 body_map.insert(
727 "has_issues".to_string(),
728 serde_json::Value::Bool(i == "true"),
729 );
730 }
731 if let Some(p) = enable_projects {
732 body_map.insert(
733 "has_projects".to_string(),
734 serde_json::Value::Bool(p == "true"),
735 );
736 }
737
738 let body_value = serde_json::Value::Object(body_map);
739
740 let path = format!("/repos/{}/{}", spec.owner, spec.repo);
741 let response = client
742 .request("PATCH", &path, &[], Some(serde_json::to_vec(&body_value)?))
743 .context("failed to update repository")?;
744
745 let status = response.status();
746 if status == reqwest::StatusCode::NOT_FOUND {
747 anyhow::bail!("repository '{spec}' not found");
748 }
749 if !status.is_success() {
750 let err_body: serde_json::Value = response.json().unwrap_or_default();
751 let msg = err_body["message"].as_str().unwrap_or("update failed");
752 anyhow::bail!("failed to update repository '{spec}': {msg}");
753 }
754
755 let updated: serde_json::Value = response
756 .json()
757 .context("failed to parse repository response")?;
758
759 if !add_topic.is_empty() || !remove_topic.is_empty() {
761 let topics_path = format!("/repos/{}/{}/topics", spec.owner, spec.repo);
763 let topics_response = client.get(&topics_path).context("failed to fetch topics")?;
764 let mut current_topics: Vec<String> = topics_response
765 .json::<serde_json::Value>()
766 .ok()
767 .and_then(|v| v["names"].as_array().cloned())
768 .unwrap_or_default()
769 .into_iter()
770 .filter_map(|v| v.as_str().map(String::from))
771 .collect();
772
773 for topic in add_topic {
775 if !current_topics.contains(topic) {
776 current_topics.push(topic.clone());
777 }
778 }
779
780 current_topics.retain(|t| !remove_topic.contains(t));
782
783 let topics_body = serde_json::json!({"names": current_topics});
785 let accept_header = "application/vnd.github.mercy-preview+json".to_string();
786 if let Err(e) = client.request(
787 "PUT",
788 &topics_path,
789 &[format!("Accept: {accept_header}")],
790 Some(serde_json::to_vec(&topics_body)?),
791 ) {
792 eprintln!("Warning: failed to update topics: {e}");
793 }
794 }
795
796 let full_name = updated["full_name"].as_str().unwrap_or("");
798 let desc = updated["description"].as_str().unwrap_or("—");
799 let vis = updated["visibility"].as_str().unwrap_or("public");
800 let branch = updated["default_branch"].as_str().unwrap_or("—");
801 let wiki = updated["has_wiki"].as_bool().unwrap_or(true);
802 let issues = updated["has_issues"].as_bool().unwrap_or(true);
803 let projects = updated["has_projects"].as_bool().unwrap_or(true);
804
805 println!("Updated repository {full_name}");
806 println!(" description: {desc}");
807 println!(" visibility: {vis}");
808 println!(" default: {branch}");
809 println!(" wiki: {wiki}");
810 println!(" issues: {issues}");
811 println!(" projects: {projects}");
812
813 Ok(())
814}
815
816fn repo_transfer(
825 target: &str,
826 repo: Option<&str>,
827 new_name: Option<&str>,
828 _teams: &[String],
829 yes: bool,
830 hostname: Option<&str>,
831) -> anyhow::Result<()> {
832 let spec = match repo {
833 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
834 None => detect_remote().ok_or_else(|| {
835 anyhow::anyhow!(
836 "could not detect repository from current directory; specify OWNER/REPO with --repo"
837 )
838 })?,
839 };
840
841 if !yes {
843 eprintln!("WARNING: This will transfer repository '{spec}' to '{target}'.");
844 eprint!("Type the repository name '{spec}' to confirm: ");
845 std::io::stdout().flush().ok();
846 let mut input = String::new();
847 std::io::stdin()
848 .read_line(&mut input)
849 .context("failed to read input")?;
850 let input = input.trim();
851 if input != spec.to_string() {
852 anyhow::bail!("confirmation failed: expected '{spec}', got '{input}'");
853 }
854 }
855
856 let host = hostname.unwrap_or("github.com");
857 let client = Client::new(host).context("failed to create HTTP client")?;
858
859 let mut body_map = serde_json::Map::new();
861 body_map.insert(
862 "new_owner".to_string(),
863 serde_json::Value::String(target.to_string()),
864 );
865 if let Some(n) = new_name {
866 body_map.insert(
867 "new_name".to_string(),
868 serde_json::Value::String(n.to_string()),
869 );
870 }
871
872 let body_value = serde_json::Value::Object(body_map);
873
874 let path = format!("/repos/{}/{}/transfer", spec.owner, spec.repo);
875 let response = client
876 .request("POST", &path, &[], Some(serde_json::to_vec(&body_value)?))
877 .context("failed to transfer repository")?;
878
879 let status = response.status();
880 if status == reqwest::StatusCode::NOT_FOUND {
881 anyhow::bail!("repository '{spec}' not found");
882 }
883 if status == reqwest::StatusCode::FORBIDDEN {
884 anyhow::bail!("you do not have permission to transfer '{spec}' to '{target}'");
885 }
886 if !status.is_success() {
887 let err_body: serde_json::Value = response.json().unwrap_or_default();
888 let msg = err_body["message"].as_str().unwrap_or("transfer failed");
889 anyhow::bail!("failed to transfer repository: {msg}");
890 }
891
892 let result: serde_json::Value = response
893 .json()
894 .context("failed to parse transfer response")?;
895
896 let html_url = result["html_url"].as_str().unwrap_or("");
897 println!("Transferred repository to {target}");
898 println!("{html_url}");
899
900 Ok(())
901}
902
903fn filter_repos(
905 repos: Vec<serde_json::Value>,
906 visibility: &str,
907 fork: &str,
908 language: Option<&str>,
909 limit: u32,
910) -> Vec<serde_json::Value> {
911 let mut filtered = repos;
912
913 if visibility != "all" {
915 filtered.retain(|repo| {
916 let is_private = repo["private"].as_bool().unwrap_or(false);
917 match visibility {
918 "public" => !is_private,
919 "private" => is_private,
920 _ => true,
921 }
922 });
923 }
924
925 match fork {
927 "exclude" => filtered.retain(|repo| !repo["fork"].as_bool().unwrap_or(false)),
928 "only" => filtered.retain(|repo| repo["fork"].as_bool().unwrap_or(false)),
929 _ => {} }
931
932 if let Some(lang) = language {
934 filtered.retain(|repo| {
935 repo["language"]
936 .as_str()
937 .is_some_and(|l| l.eq_ignore_ascii_case(lang))
938 });
939 }
940
941 filtered.truncate(limit as usize);
943 filtered
944}
945
946fn output_repos(repos: &Vec<serde_json::Value>, json: Option<Vec<String>>) {
948 if let Some(fields) = json {
949 let fields_ref: Option<&[String]> = if fields.is_empty() {
950 None
951 } else {
952 Some(&fields)
953 };
954 print_json(repos, fields_ref);
955 } else {
956 print_repo_list_table(repos);
957 }
958}
959
960fn print_repo_list_table(repos: &[serde_json::Value]) {
964 if repos.is_empty() {
965 println!("No repositories found.");
966 return;
967 }
968
969 let name_width = 30;
971 let desc_width = 50;
972 let vis_width = 10;
973 let lang_width = 14;
974 let date_width = 16;
975
976 println!(
978 "{:<name_width$} {:<desc_width$} {:<vis_width$} {:<lang_width$} {:<date_width$}",
979 "NAME", "DESCRIPTION", "VISIBILITY", "LANGUAGE", "UPDATED",
980 );
981
982 for repo in repos {
983 let name = repo["full_name"].as_str().unwrap_or("—");
984 let description = repo["description"].as_str().unwrap_or("—");
985 let is_private = repo["private"].as_bool().unwrap_or(false);
986 let visibility = if is_private { "private" } else { "public" };
987 let language = repo["language"].as_str().unwrap_or("—");
988 let updated = repo["updated_at"]
989 .as_str()
990 .map_or_else(|| "—".to_string(), format_date);
991
992 let name_truncated = crate::cmd::util::truncate(name, name_width);
993 let desc_truncated = crate::cmd::util::truncate(description, desc_width);
994
995 println!(
996 "{name_truncated:<name_width$} {desc_truncated:<desc_width$} {visibility:<vis_width$} {language:<lang_width$} {updated:<date_width$}",
997 );
998 }
999}
1000
1001fn print_repo_table(repo: &serde_json::Value) {
1003 let full_name = repo["full_name"].as_str().unwrap_or("—");
1004 let description = repo["description"].as_str().unwrap_or("—");
1005 let html_url = repo["html_url"].as_str().unwrap_or("—");
1006 let is_private = repo["private"].as_bool().unwrap_or(false);
1007 let visibility = if is_private { "private" } else { "public" };
1008 let stars = repo["stargazers_count"].as_u64().unwrap_or(0);
1009 let forks = repo["forks_count"].as_u64().unwrap_or(0);
1010 let issues = repo["open_issues_count"].as_u64().unwrap_or(0);
1011 let language = repo["language"].as_str().unwrap_or("—");
1012 let license_name = repo["license"]["spdx_id"]
1013 .as_str()
1014 .or_else(|| repo["license"]["name"].as_str())
1015 .unwrap_or("—");
1016 let default_branch = repo["default_branch"].as_str().unwrap_or("—");
1017 let pushed_at = repo["pushed_at"].as_str().unwrap_or("—");
1018
1019 println!("name: {full_name}");
1020 println!("description: {description}");
1021 println!("url: {html_url}");
1022 println!("visibility: {visibility}");
1023 println!("stars: {}", format_count(stars));
1024 println!("forks: {}", format_count(forks));
1025 println!("issues: {}", format_count(issues));
1026 println!("language: {language}");
1027 println!("license: {license_name}");
1028 println!("default: {default_branch}");
1029 println!("updated: {}", format_date(pushed_at));
1030}
1031
1032fn sync(repo: Option<&str>, branch: Option<&str>, hostname: Option<&str>) -> anyhow::Result<()> {
1041 let spec = match repo {
1042 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1043 None => detect_remote().ok_or_else(|| {
1044 anyhow::anyhow!(
1045 "could not detect repository from current directory; specify OWNER/REPO with --repo"
1046 )
1047 })?,
1048 };
1049
1050 let host = hostname.unwrap_or("github.com");
1051 let client = Client::new(host).context("failed to create HTTP client")?;
1052
1053 let path = format!("/repos/{}/{}/merge-upstream", spec.owner, spec.repo);
1054
1055 let mut body_map = serde_json::Map::new();
1056 body_map.insert(
1057 "branch".to_string(),
1058 serde_json::Value::String(branch.unwrap_or("main").to_string()),
1059 );
1060 let body_value = serde_json::Value::Object(body_map);
1061
1062 let response = client
1063 .post(&path, &body_value)
1064 .context("failed to sync repository")?;
1065
1066 let status = response.status();
1067 if status == reqwest::StatusCode::NOT_FOUND {
1068 anyhow::bail!("repository '{spec}' not found or is not a fork");
1069 }
1070 if status == reqwest::StatusCode::CONFLICT {
1071 anyhow::bail!(
1072 "sync would result in merge conflicts; resolve conflicts manually and try again"
1073 );
1074 }
1075 if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
1076 let err_body: serde_json::Value = response.json().unwrap_or_default();
1077 let msg = err_body["message"].as_str().unwrap_or(
1078 "sync failed - check that the repository is a fork and has an upstream configured",
1079 );
1080 anyhow::bail!("{msg}");
1081 }
1082 if !status.is_success() {
1083 let err_body: serde_json::Value = response.json().unwrap_or_default();
1084 let msg = err_body["message"].as_str().unwrap_or("sync failed");
1085 anyhow::bail!("failed to sync '{spec}': {msg}");
1086 }
1087
1088 let result: serde_json::Value = response.json().context("failed to parse sync response")?;
1089
1090 if let Some(merge_sha) = result["merge_after"].as_str() {
1091 println!("✓ Synced '{spec}' with upstream (merge commit: {merge_sha})");
1092 } else {
1093 println!("✓ '{spec}' is already up to date with upstream");
1094 }
1095
1096 Ok(())
1097}
1098
1099fn open_in_browser(url: &str) {
1101 #[cfg(target_os = "linux")]
1102 {
1103 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
1104 }
1105 #[cfg(target_os = "macos")]
1106 {
1107 let _ = std::process::Command::new("open").arg(url).spawn();
1108 }
1109 #[cfg(target_os = "windows")]
1110 {
1111 let _ = std::process::Command::new("cmd")
1112 .args(["/c", "start", url])
1113 .spawn();
1114 }
1115 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1116 {
1117 println!("Open {url} in your browser");
1118 }
1119}
1120
1121#[cfg(test)]
1122#[allow(clippy::expect_used)]
1123mod tests {
1124 use super::*;
1125 use serde_json::json;
1126
1127 #[test]
1128 fn print_repo_table_basic() {
1129 let repo = json!({
1130 "full_name": "octocat/hello-world",
1131 "description": "My first repository",
1132 "html_url": "https://github.com/octocat/hello-world",
1133 "private": false,
1134 "stargazers_count": 1234,
1135 "forks_count": 56,
1136 "open_issues_count": 12,
1137 "language": "Rust",
1138 "license": { "spdx_id": "MIT" },
1139 "default_branch": "main",
1140 "pushed_at": "2024-01-15T10:30:00Z"
1141 });
1142 print_repo_table(&repo);
1144 }
1145
1146 #[test]
1147 fn print_repo_table_private() {
1148 let repo = json!({
1149 "full_name": "org/private-repo",
1150 "description": null,
1151 "html_url": "https://github.com/org/private-repo",
1152 "private": true,
1153 "stargazers_count": 0,
1154 "forks_count": 0,
1155 "open_issues_count": 0,
1156 "language": null,
1157 "license": null,
1158 "default_branch": "main",
1159 "pushed_at": "2024-01-15T10:30:00Z"
1160 });
1161 print_repo_table(&repo);
1163 }
1164
1165 #[test]
1166 fn print_repo_table_no_license() {
1167 let repo = json!({
1168 "full_name": "user/test",
1169 "description": "A test repo",
1170 "html_url": "https://github.com/user/test",
1171 "private": false,
1172 "stargazers_count": 42,
1173 "forks_count": 7,
1174 "open_issues_count": 3,
1175 "language": "Python",
1176 "license": null,
1177 "default_branch": "master",
1178 "pushed_at": "2023-12-25T00:00:00Z"
1179 });
1180 print_repo_table(&repo);
1182 }
1183
1184 #[test]
1185 fn open_in_browser_does_not_panic() {
1186 open_in_browser("https://github.com/octocat/hello-world");
1188 }
1189
1190 #[test]
1191 fn print_repo_list_table_basic() {
1192 let repos = vec![json!({
1193 "full_name": "octocat/hello-world",
1194 "description": "My first repository",
1195 "private": false,
1196 "language": "Rust",
1197 "updated_at": "2024-01-15T10:30:00Z"
1198 })];
1199 print_repo_list_table(&repos);
1200 }
1201
1202 #[test]
1203 fn print_repo_list_table_empty() {
1204 let repos: Vec<serde_json::Value> = vec![];
1205 print_repo_list_table(&repos);
1206 }
1207
1208 #[test]
1209 fn print_repo_list_table_multiple() {
1210 let repos = vec![
1211 json!({
1212 "full_name": "alice/project-a",
1213 "description": "First project",
1214 "private": false,
1215 "language": "Rust",
1216 "updated_at": "2024-01-15T10:30:00Z"
1217 }),
1218 json!({
1219 "full_name": "bob/private-repo",
1220 "description": "A secret project with a very long description that should be truncated",
1221 "private": true,
1222 "language": "Python",
1223 "updated_at": "2024-02-20T12:00:00Z"
1224 }),
1225 ];
1226 print_repo_list_table(&repos);
1227 }
1228
1229 #[test]
1230 fn print_repo_list_table_null_fields() {
1231 let repos = vec![json!({
1232 "full_name": "test/repo",
1233 "description": null,
1234 "private": false,
1235 "language": null,
1236 "updated_at": null
1237 })];
1238 print_repo_list_table(&repos);
1239 }
1240
1241 #[test]
1242 fn filter_repos_by_visibility() {
1243 let repos = vec![
1244 json!({"full_name": "a/public", "private": false, "fork": false, "language": "Rust"}),
1245 json!({"full_name": "b/private", "private": true, "fork": false, "language": "Rust"}),
1246 ];
1247 let filtered = filter_repos(repos, "public", "include", None, 30);
1248 assert_eq!(filtered.len(), 1);
1249 assert_eq!(filtered[0]["full_name"], "a/public");
1250 }
1251
1252 #[test]
1253 fn filter_repos_by_fork_exclude() {
1254 let repos = vec![
1255 json!({"full_name": "a/original", "private": false, "fork": false, "language": "Rust"}),
1256 json!({"full_name": "b/forked", "private": false, "fork": true, "language": "Rust"}),
1257 ];
1258 let filtered = filter_repos(repos, "all", "exclude", None, 30);
1259 assert_eq!(filtered.len(), 1);
1260 assert_eq!(filtered[0]["full_name"], "a/original");
1261 }
1262
1263 #[test]
1264 fn filter_repos_by_fork_only() {
1265 let repos = vec![
1266 json!({"full_name": "a/original", "private": false, "fork": false, "language": "Rust"}),
1267 json!({"full_name": "b/forked", "private": false, "fork": true, "language": "Rust"}),
1268 ];
1269 let filtered = filter_repos(repos, "all", "only", None, 30);
1270 assert_eq!(filtered.len(), 1);
1271 assert_eq!(filtered[0]["full_name"], "b/forked");
1272 }
1273
1274 #[test]
1275 fn filter_repos_by_language() {
1276 let repos = vec![
1277 json!({"full_name": "a/rust-repo", "private": false, "fork": false, "language": "Rust"}),
1278 json!({"full_name": "b/python-repo", "private": false, "fork": false, "language": "Python"}),
1279 ];
1280 let filtered = filter_repos(repos, "all", "include", Some("Rust"), 30);
1281 assert_eq!(filtered.len(), 1);
1282 assert_eq!(filtered[0]["full_name"], "a/rust-repo");
1283 }
1284
1285 #[test]
1286 fn filter_repos_by_limit() {
1287 let repos: Vec<serde_json::Value> = (0..10)
1288 .map(|i| json!({"full_name": format!("owner/repo{i}"), "private": false, "fork": false, "language": "Rust"}))
1289 .collect();
1290 let filtered = filter_repos(repos, "all", "include", None, 5);
1291 assert_eq!(filtered.len(), 5);
1292 }
1293
1294 #[test]
1295 fn clone_directory_name_from_url() {
1296 let url = "https://github.com/octocat/hello-world.git";
1298 let name = url
1299 .rfind('/')
1300 .map_or(url, |pos| url[pos + 1..].trim_end_matches(".git"));
1301 assert_eq!(name, "hello-world");
1302
1303 let url = "https://github.com/octocat/hello-world";
1305 let name = url
1306 .rfind('/')
1307 .map_or(url, |pos| url[pos + 1..].trim_end_matches(".git"));
1308 assert_eq!(name, "hello-world");
1309
1310 let url = "git@github.com:octocat/repo.git";
1312 let name = url
1313 .rfind('/')
1314 .map_or(url, |pos| url[pos + 1..].trim_end_matches(".git"));
1315 assert_eq!(name, "repo");
1316 }
1317}