bitbucket_cli/cli/
repo.rs

1use anyhow::{Context, Result};
2use clap::Subcommand;
3use colored::Colorize;
4use tabled::{Table, Tabled};
5
6use crate::api::BitbucketClient;
7use crate::models::CreateRepositoryRequest;
8
9#[derive(Subcommand)]
10pub enum RepoCommands {
11    /// List repositories in a workspace
12    List {
13        /// Workspace slug
14        workspace: String,
15
16        /// Number of results per page
17        #[arg(short, long, default_value = "25")]
18        limit: u32,
19    },
20
21    /// View repository details
22    View {
23        /// Repository in format workspace/repo-slug
24        repo: String,
25
26        /// Open in browser
27        #[arg(short, long)]
28        web: bool,
29    },
30
31    /// Clone a repository
32    Clone {
33        /// Repository in format workspace/repo-slug
34        repo: String,
35
36        /// Directory to clone into
37        #[arg(short, long)]
38        dir: Option<String>,
39    },
40
41    /// Create a new repository
42    Create {
43        /// Workspace slug
44        workspace: String,
45
46        /// Repository name
47        name: String,
48
49        /// Repository description
50        #[arg(short, long)]
51        description: Option<String>,
52
53        /// Make repository public
54        #[arg(long)]
55        public: bool,
56
57        /// Project key to add repository to
58        #[arg(short, long)]
59        project: Option<String>,
60    },
61
62    /// Fork a repository
63    Fork {
64        /// Repository to fork in format workspace/repo-slug
65        repo: String,
66
67        /// Workspace to fork into
68        #[arg(short, long)]
69        workspace: Option<String>,
70
71        /// New repository name
72        #[arg(short, long)]
73        name: Option<String>,
74    },
75
76    /// Delete a repository
77    Delete {
78        /// Repository in format workspace/repo-slug
79        repo: String,
80
81        /// Skip confirmation prompt
82        #[arg(short, long)]
83        yes: bool,
84    },
85}
86
87#[derive(Tabled)]
88struct RepoRow {
89    #[tabled(rename = "NAME")]
90    name: String,
91    #[tabled(rename = "DESCRIPTION")]
92    description: String,
93    #[tabled(rename = "PRIVATE")]
94    private: String,
95    #[tabled(rename = "UPDATED")]
96    updated: String,
97}
98
99impl RepoCommands {
100    pub async fn run(self) -> Result<()> {
101        match self {
102            RepoCommands::List { workspace, limit } => {
103                let client = BitbucketClient::from_stored()?;
104                let repos = client
105                    .list_repositories(&workspace, None, Some(limit))
106                    .await?;
107
108                if repos.values.is_empty() {
109                    println!("No repositories found in workspace '{}'", workspace);
110                    return Ok(());
111                }
112
113                let rows: Vec<RepoRow> = repos
114                    .values
115                    .iter()
116                    .map(|r| RepoRow {
117                        name: r.full_name.clone(),
118                        description: r
119                            .description
120                            .clone()
121                            .unwrap_or_default()
122                            .chars()
123                            .take(40)
124                            .collect::<String>(),
125                        private: if r.is_private.unwrap_or(false) { "Yes" } else { "No" }.to_string(),
126                        updated: r
127                            .updated_on
128                            .map(|d| d.format("%Y-%m-%d").to_string())
129                            .unwrap_or_default(),
130                    })
131                    .collect();
132
133                let table = Table::new(rows).to_string();
134                println!("{}", table);
135
136                if repos.next.is_some() {
137                    println!(
138                        "\n{} More repositories available. Use --limit to see more.",
139                        "ℹ".blue()
140                    );
141                }
142
143                Ok(())
144            }
145
146            RepoCommands::View { repo, web } => {
147                let (workspace, repo_slug) = parse_repo(&repo)?;
148                let client = BitbucketClient::from_stored()?;
149                let repository = client.get_repository(&workspace, &repo_slug).await?;
150
151                if web {
152                    if let Some(links) = &repository.links {
153                        if let Some(html) = &links.html {
154                            open::that(&html.href)?;
155                            println!("Opened {} in browser", html.href.cyan());
156                            return Ok(());
157                        }
158                    }
159                    anyhow::bail!("Could not find repository URL");
160                }
161
162                println!("{}", repository.full_name.bold());
163                println!("{}", "─".repeat(50));
164
165                if let Some(desc) = &repository.description {
166                    if !desc.is_empty() {
167                        println!("{}", desc);
168                        println!();
169                    }
170                }
171
172                println!(
173                    "{} {}",
174                    "Private:".dimmed(),
175                    if repository.is_private.unwrap_or(false) { "Yes" } else { "No" }
176                );
177                println!("{} {}", "SCM:".dimmed(), repository.scm.as_deref().unwrap_or("unknown"));
178
179                if let Some(lang) = &repository.language {
180                    if !lang.is_empty() {
181                        println!("{} {}", "Language:".dimmed(), lang);
182                    }
183                }
184
185                if let Some(branch) = &repository.mainbranch {
186                    println!("{} {}", "Main branch:".dimmed(), branch.name);
187                }
188
189                if let Some(size) = repository.size {
190                    let size_mb = size as f64 / (1024.0 * 1024.0);
191                    println!("{} {:.2} MB", "Size:".dimmed(), size_mb);
192                }
193
194                if let Some(created) = repository.created_on {
195                    println!("{} {}", "Created:".dimmed(), created.format("%Y-%m-%d"));
196                }
197
198                if let Some(updated) = repository.updated_on {
199                    println!("{} {}", "Updated:".dimmed(), updated.format("%Y-%m-%d"));
200                }
201
202                if let Some(links) = &repository.links {
203                    println!();
204                    if let Some(html) = &links.html {
205                        println!("{} {}", "Web:".dimmed(), html.href.cyan());
206                    }
207                    if let Some(clone_links) = &links.clone {
208                        for link in clone_links {
209                            println!("{} {} ({})", "Clone:".dimmed(), link.href, link.name);
210                        }
211                    }
212                }
213
214                Ok(())
215            }
216
217            RepoCommands::Clone { repo, dir } => {
218                let (workspace, repo_slug) = parse_repo(&repo)?;
219                let client = BitbucketClient::from_stored()?;
220                let repository = client.get_repository(&workspace, &repo_slug).await?;
221
222                let clone_url = repository
223                    .links
224                    .as_ref()
225                    .and_then(|l| l.clone.as_ref())
226                    .and_then(|links| links.iter().find(|l| l.name == "ssh" || l.name == "https"))
227                    .map(|l| &l.href)
228                    .context("Could not find clone URL")?;
229
230                let target_dir = dir.unwrap_or_else(|| repo_slug.clone());
231
232                println!("Cloning {} into {}...", repo.cyan(), target_dir);
233
234                let status = std::process::Command::new("git")
235                    .args(["clone", clone_url, &target_dir])
236                    .status()
237                    .context("Failed to run git clone")?;
238
239                if status.success() {
240                    println!("{} Successfully cloned repository", "✓".green());
241                } else {
242                    anyhow::bail!("git clone failed");
243                }
244
245                Ok(())
246            }
247
248            RepoCommands::Create {
249                workspace,
250                name,
251                description,
252                public,
253                project,
254            } => {
255                let client = BitbucketClient::from_stored()?;
256
257                let slug = name.to_lowercase().replace(' ', "-");
258
259                let request = CreateRepositoryRequest {
260                    scm: "git".to_string(),
261                    name: Some(name.clone()),
262                    description,
263                    is_private: Some(!public),
264                    project: project.map(|key| crate::models::ProjectKey { key }),
265                    ..Default::default()
266                };
267
268                let repository = client
269                    .create_repository(&workspace, &slug, &request)
270                    .await?;
271
272                println!(
273                    "{} Created repository {}",
274                    "✓".green(),
275                    repository.full_name.cyan()
276                );
277
278                if let Some(links) = &repository.links {
279                    if let Some(html) = &links.html {
280                        println!("{} {}", "URL:".dimmed(), html.href);
281                    }
282                }
283
284                Ok(())
285            }
286
287            RepoCommands::Fork {
288                repo,
289                workspace,
290                name,
291            } => {
292                let (src_workspace, src_repo) = parse_repo(&repo)?;
293                let client = BitbucketClient::from_stored()?;
294
295                let forked = client
296                    .fork_repository(
297                        &src_workspace,
298                        &src_repo,
299                        workspace.as_deref(),
300                        name.as_deref(),
301                    )
302                    .await?;
303
304                println!("{} Forked to {}", "✓".green(), forked.full_name.cyan());
305
306                Ok(())
307            }
308
309            RepoCommands::Delete { repo, yes } => {
310                let (workspace, repo_slug) = parse_repo(&repo)?;
311
312                if !yes {
313                    use dialoguer::Confirm;
314                    let confirmed = Confirm::new()
315                        .with_prompt(format!(
316                            "Are you sure you want to delete {}? This cannot be undone!",
317                            repo.red()
318                        ))
319                        .default(false)
320                        .interact()?;
321
322                    if !confirmed {
323                        println!("Aborted");
324                        return Ok(());
325                    }
326                }
327
328                let client = BitbucketClient::from_stored()?;
329                client.delete_repository(&workspace, &repo_slug).await?;
330
331                println!("{} Deleted repository {}", "✓".green(), repo);
332
333                Ok(())
334            }
335        }
336    }
337}
338
339fn parse_repo(repo: &str) -> Result<(String, String)> {
340    let parts: Vec<&str> = repo.split('/').collect();
341    if parts.len() != 2 {
342        anyhow::bail!(
343            "Invalid repository format. Expected 'workspace/repo-slug', got '{}'",
344            repo
345        );
346    }
347    Ok((parts[0].to_string(), parts[1].to_string()))
348}