Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pub enum Commands {
/// IDE Management
Ide(IdeCommand),

/// Clone a Git repository.
Clone(CloneArgs),

/// Open a project (equivalent to `project open`).
Open(OpenArgs),
}
Expand All @@ -40,6 +43,21 @@ pub struct OpenArgs {
pub ide: Option<IdeSelection>,
}

/// Arguments for cloning a Git repository.
#[derive(Args)]
pub struct CloneArgs {
/// The Git repository URL to clone.
pub url: String,

/// Specify which project root to use by index or exact path (defaults to the first root).
#[arg(short, long)]
pub root: Option<String>,

/// Clone into a specific directory name instead of inferring from the URL.
#[arg(short, long)]
pub name: Option<String>,
}

/// Subcommands for `dev project`.
#[derive(Subcommand)]
pub enum ProjectSubcommand {
Expand Down
76 changes: 76 additions & 0 deletions src/commands/clone.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Clone command implementation.

use anyhow::{Result, anyhow};
use std::path::PathBuf;
use std::process::{Command, Stdio};

use crate::cli::CloneArgs;
use crate::config::Config;

/// Executes the clone command.
pub fn execute(args: CloneArgs) -> Result<()> {
let cfg = Config::load()?;
if cfg.projects_root.is_empty() {
return Err(anyhow!("No project roots configured. Please add a root first."));
}

// Determine target root
let target_root = match args.root {
Some(ref root_arg) => {
// Check if it's an index
if let Ok(idx) = root_arg.parse::<usize>() {
if idx < cfg.projects_root.len() {
cfg.projects_root[idx].clone()
} else {
return Err(anyhow!(
"Root index {} is out of bounds. You have {} configured roots.",
idx,
cfg.projects_root.len()
));
}
} else {
// Otherwise treat as path string
PathBuf::from(root_arg)
}
}
None => cfg.projects_root[0].clone(),
};

if !target_root.exists() {
std::fs::create_dir_all(&target_root)?;
}

// Extract repo name if not provided
let repo_name = match args.name {
Some(name) => name,
None => {
let path = std::path::Path::new(&args.url);
let file_name = path.file_name().unwrap_or_default().to_string_lossy();
let name = if file_name.is_empty() { "repository" } else { file_name.as_ref() };
name.strip_suffix(".git").unwrap_or(name).to_string()
}
};

let target_path = target_root.join(repo_name);

println!("Cloning '{}' into '{}'...", args.url, target_path.display());

let mut cmd = Command::new("git");
cmd.arg("clone");
cmd.arg(&args.url);
cmd.arg(&target_path);

// Stream output directly to terminal so user can interact if auth is needed
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
cmd.stdin(Stdio::inherit());

let status = cmd.status().map_err(|e| anyhow!("Failed to spawn git process: {}", e))?;

if status.success() {
println!("Successfully cloned into {}", target_path.display());
Ok(())
} else {
Err(anyhow!("git clone failed with exit code: {}", status))
}
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
//! - [`ide`] — IDE management (`dev ide`)
//! - [`project`] — Project management (`dev project`)

pub mod clone;
pub mod config;
pub mod ide;
pub mod project;
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ fn main() -> Result<()> {
Some(Commands::Project(cmd)) => commands::project::execute(cmd)?,
Some(Commands::Config(cmd)) => commands::config::execute(cmd)?,
Some(Commands::Ide(cmd)) => commands::ide::execute(cmd)?,
Some(Commands::Clone(args)) => commands::clone::execute(args)?,
Some(Commands::Open(args)) => commands::project::open_shortcut(args)?,

None => tui::run()?,
Expand Down
200 changes: 200 additions & 0 deletions tests/cli_clone.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
mod common;

use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
use serial_test::serial;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;

fn setup_test_env() -> (AssertCommand, TempDir, TempDir) {
let config_dir = tempfile::tempdir().unwrap();
let roots_dir = tempfile::tempdir().unwrap();

let config = format!(
r#"
default_ide = "Cursor"
projects_root = ["{}"]
"#,
roots_dir.path().display().to_string().replace('\\', "\\\\")
);

std::fs::write(config_dir.path().join("config.toml"), config).unwrap();

let mut cmd = AssertCommand::cargo_bin("dev").unwrap();
cmd.env("DEVCLI_CONFIG_DIR", config_dir.path());
cmd.env("DEVCLI_SKIP_ONBOARDING", "1");

(cmd, config_dir, roots_dir)
}

fn setup_test_env_with_roots(roots: &[PathBuf]) -> (AssertCommand, TempDir) {
let config_dir = tempfile::tempdir().unwrap();
let roots_toml = roots
.iter()
.map(|r| format!("\"{}\"", r.display().to_string().replace('\\', "\\\\")))
.collect::<Vec<_>>()
.join(", ");

let config = format!(
r#"
default_ide = "Cursor"
projects_root = [{roots_toml}]
"#
);

std::fs::write(config_dir.path().join("config.toml"), config).unwrap();

let mut cmd = AssertCommand::cargo_bin("dev").unwrap();
cmd.env("DEVCLI_CONFIG_DIR", config_dir.path());
cmd.env("DEVCLI_SKIP_ONBOARDING", "1");

(cmd, config_dir)
}

#[test]
#[serial]
fn clone_command_fails_without_url() {
let (mut cmd, _cfg, _roots) = setup_test_env();

cmd.args(["clone"]).assert().failure().stderr(predicate::str::contains("Usage"));
}

#[test]
#[serial]
fn clone_command_fails_when_no_roots_configured() {
let (mut cmd, _cfg) = setup_test_env_with_roots(&[]);

let source_dir = tempfile::tempdir().unwrap();
Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap();
let source_url = source_dir.path().to_string_lossy().to_string();

cmd.args(["clone", &source_url])
.assert()
.failure()
.stderr(predicate::str::contains("No project roots configured"));
}

#[test]
#[serial]
fn clone_command_clones_local_repo() {
let (mut cmd, _cfg, roots) = setup_test_env();

// Create a real local git repo to clone from
let source_dir = tempfile::tempdir().unwrap();
Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap();

let source_url = source_dir.path().to_string_lossy().to_string();

cmd.args(["clone", &source_url]).assert().success();

// The cloned repo should exist in roots_dir
let repo_name = source_dir.path().file_name().unwrap().to_string_lossy();
let target_path = roots.path().join(repo_name.as_ref());
assert!(target_path.exists());
assert!(target_path.join(".git").exists());
}

#[test]
#[serial]
fn clone_command_with_custom_name() {
let (mut cmd, _cfg, roots) = setup_test_env();

let source_dir = tempfile::tempdir().unwrap();
Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap();

let source_url = source_dir.path().to_string_lossy().to_string();

cmd.args(["clone", &source_url, "--name", "CustomRepoName"]).assert().success();

let target_path = roots.path().join("CustomRepoName");
assert!(target_path.exists());
assert!(target_path.join(".git").exists());
}

#[test]
#[serial]
fn clone_command_strips_git_suffix() {
let (mut cmd, _cfg, roots) = setup_test_env();

let source_parent = tempfile::tempdir().unwrap();
let source_dir = source_parent.path().join("sample-repo.git");
std::fs::create_dir_all(&source_dir).unwrap();
Command::new("git").arg("init").current_dir(&source_dir).status().unwrap();

let source_url = source_dir.to_string_lossy().to_string();

cmd.args(["clone", &source_url]).assert().success();

let target_path = roots.path().join("sample-repo");
assert!(target_path.exists());
assert!(target_path.join(".git").exists());
}

#[test]
#[serial]
fn clone_command_with_root_index() {
let root1 = tempfile::tempdir().unwrap();
let root2 = tempfile::tempdir().unwrap();

let (mut cmd, _cfg) =
setup_test_env_with_roots(&[root1.path().to_path_buf(), root2.path().to_path_buf()]);

let source_dir = tempfile::tempdir().unwrap();
Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap();

let source_url = source_dir.path().to_string_lossy().to_string();

cmd.args(["clone", &source_url, "--root", "1", "--name", "IndexedRepo"]).assert().success();

let target_path = root2.path().join("IndexedRepo");
assert!(target_path.exists());
assert!(target_path.join(".git").exists());
assert!(!root1.path().join("IndexedRepo").exists());
}

#[test]
#[serial]
fn clone_command_with_out_of_bounds_root_index() {
let (mut cmd, _cfg, _roots) = setup_test_env();

let source_dir = tempfile::tempdir().unwrap();
Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap();
let source_url = source_dir.path().to_string_lossy().to_string();

cmd.args(["clone", &source_url, "--root", "5"])
.assert()
.failure()
.stderr(predicate::str::contains("Root index 5 is out of bounds"));
}

#[test]
#[serial]
fn clone_command_with_custom_root_path_creates_directory() {
let (mut cmd, _cfg, _roots) = setup_test_env();

let parent_dir = tempfile::tempdir().unwrap();
let nonexistent_root = parent_dir.path().join("new_subfolder");

let source_dir = tempfile::tempdir().unwrap();
Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap();
let source_url = source_dir.path().to_string_lossy().to_string();

let root_str = nonexistent_root.to_string_lossy().to_string();
cmd.args(["clone", &source_url, "--root", &root_str, "--name", "PathRepo"]).assert().success();

let target_path = nonexistent_root.join("PathRepo");
assert!(target_path.exists());
assert!(target_path.join(".git").exists());
}

#[test]
#[serial]
fn clone_command_fails_on_git_clone_error() {
let (mut cmd, _cfg, _roots) = setup_test_env();

cmd.args(["clone", "file:///nonexistent/invalid/repo.git"])
.assert()
.failure()
.stderr(predicate::str::contains("git clone failed"));
}
Loading
Loading