use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use clap::Subcommand;
use netsky_core::config::workspace_mirror_remotes;
use netsky_core::paths::resolve_netsky_dir;
const DEFAULT_ORIGIN_URL: &str = "https://github.com/lostmygithubaccount/netsky.git";
const ENV_WORKSPACE_ORIGIN_URL: &str = "NETSKY_WORKSPACE_ORIGIN_URL";
#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum WorkspaceCommand {
Clone {
#[arg(value_name = "TASK")]
task: String,
},
#[command(visible_alias = "list")]
Ls,
Branch {
#[arg(value_name = "TASK")]
task: String,
#[arg(value_name = "BRANCH")]
branch: String,
},
}
pub fn run(cmd: WorkspaceCommand) -> netsky_core::Result<()> {
match cmd {
WorkspaceCommand::Clone { task } => {
let repo_dir = clone_workspace(&task)?;
println!("{}", repo_dir.display());
Ok(())
}
WorkspaceCommand::Ls => list_workspaces(),
WorkspaceCommand::Branch { task, branch } => create_branch(&task, &branch),
}
}
pub(crate) fn clone_workspace(task: &str) -> netsky_core::Result<PathBuf> {
validate_task_name(task)?;
let repo_dir = repo_dir(task);
if repo_dir.is_dir() {
ensure_remote(&repo_dir, "origin", &canonical_origin_url())?;
ensure_mirror_remotes(&repo_dir)?;
return Ok(repo_dir);
}
if repo_dir.exists() {
netsky_core::bail!(
"workspace repo path exists but is not a directory: {}",
repo_dir.display()
);
}
fs::create_dir_all(repo_dir.parent().expect("repo dir has a parent"))?;
git(
None,
&[
"clone",
&canonical_origin_url(),
&repo_dir.display().to_string(),
],
)?;
ensure_mirror_remotes(&repo_dir)?;
Ok(repo_dir)
}
fn list_workspaces() -> netsky_core::Result<()> {
for task in workspace_names()? {
println!("{task}");
}
Ok(())
}
fn create_branch(task: &str, branch: &str) -> netsky_core::Result<()> {
validate_task_name(task)?;
let repo_dir = repo_dir(task);
if !repo_dir.is_dir() {
netsky_core::bail!(
"workspace repo missing: {}. Run `netsky workspace clone {task}` first.",
repo_dir.display()
);
}
git(Some(&repo_dir), &["checkout", "-b", branch])?;
println!("{branch}");
Ok(())
}
fn ensure_mirror_remotes(repo_dir: &Path) -> netsky_core::Result<()> {
for spec in workspace_mirror_remotes() {
let (name, url) = parse_remote_spec(&spec)?;
if name == "origin" {
netsky_core::bail!("workspace.mirror_remotes must not redefine `origin`");
}
ensure_remote(repo_dir, &name, &url)?;
}
Ok(())
}
fn ensure_remote(repo_dir: &Path, name: &str, url: &str) -> netsky_core::Result<()> {
let current = git(Some(repo_dir), &["remote", "get-url", name]);
match current {
Ok(existing) if existing.trim() == url => Ok(()),
Ok(_) => {
git(Some(repo_dir), &["remote", "set-url", name, url])?;
Ok(())
}
Err(_) => {
git(Some(repo_dir), &["remote", "add", name, url])?;
Ok(())
}
}
}
fn git(repo_dir: Option<&Path>, args: &[&str]) -> netsky_core::Result<String> {
let mut command = Command::new("git");
if let Some(repo_dir) = repo_dir {
command.arg("-C").arg(repo_dir);
}
command.args(args);
let output = command.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("git exited with status {}", output.status)
} else {
stderr
};
netsky_core::bail!("git {}: {detail}", args.join(" "));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn canonical_origin_url() -> String {
std::env::var(ENV_WORKSPACE_ORIGIN_URL)
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_ORIGIN_URL.to_string())
}
fn workspace_root() -> PathBuf {
resolve_netsky_dir().join("workspaces")
}
fn workspace_dir(task: &str) -> PathBuf {
workspace_root().join(task)
}
fn repo_dir(task: &str) -> PathBuf {
workspace_dir(task).join("repo")
}
fn workspace_names() -> netsky_core::Result<Vec<String>> {
let root = workspace_root();
let mut names = Vec::new();
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(names),
Err(err) => return Err(err.into()),
};
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if entry.path().join("repo").is_dir() {
names.push(entry.file_name().to_string_lossy().into_owned());
}
}
names.sort();
Ok(names)
}
fn validate_task_name(task: &str) -> netsky_core::Result<()> {
if task.is_empty() {
netsky_core::bail!("task must not be empty");
}
if task == "." || task == ".." || task.contains('/') {
netsky_core::bail!("task must be a single path segment: {task}");
}
Ok(())
}
fn parse_remote_spec(spec: &str) -> netsky_core::Result<(String, String)> {
let Some((name, url)) = spec.split_once('=') else {
netsky_core::bail!("invalid workspace mirror remote `{spec}`. Use `name=url`.");
};
let name = name.trim();
let url = url.trim();
if name.is_empty() || url.is_empty() {
netsky_core::bail!("invalid workspace mirror remote `{spec}`. Use `name=url`.");
}
Ok((name.to_string(), url.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_remote_spec_requires_name_and_url() {
let parsed = parse_remote_spec("iroh=ssh://mirror.example/netsky.git").unwrap();
assert_eq!(parsed.0, "iroh");
assert_eq!(parsed.1, "ssh://mirror.example/netsky.git");
assert!(parse_remote_spec("missing-separator").is_err());
assert!(parse_remote_spec("=ssh://mirror.example/netsky.git").is_err());
assert!(parse_remote_spec("iroh=").is_err());
}
#[test]
fn validate_task_name_rejects_path_traversal() {
assert!(validate_task_name("ok-task").is_ok());
assert!(validate_task_name("").is_err());
assert!(validate_task_name("../bad").is_err());
assert!(validate_task_name("bad/name").is_err());
}
#[test]
fn canonical_origin_url_prefers_env_override() {
unsafe {
std::env::set_var(ENV_WORKSPACE_ORIGIN_URL, "/tmp/netsky-local.git");
}
assert_eq!(canonical_origin_url(), "/tmp/netsky-local.git");
unsafe {
std::env::remove_var(ENV_WORKSPACE_ORIGIN_URL);
}
assert_eq!(canonical_origin_url(), DEFAULT_ORIGIN_URL);
}
}