diff --git a/Cargo.lock b/Cargo.lock index a5b7a916..1b90de31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -419,6 +428,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -511,6 +533,19 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -573,6 +608,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -1457,6 +1498,20 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "pm" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "colored", + "dialoguer", + "dirs", + "serde", + "serde_json", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2014,6 +2069,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -2365,6 +2426,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2684,6 +2751,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/crates/pm/Cargo.toml b/crates/pm/Cargo.toml new file mode 100644 index 00000000..9f1982c9 --- /dev/null +++ b/crates/pm/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pm" +version = "0.1.0" +edition = "2024" +description = "Project manager for multi-repo workspaces with worktree pooling" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1" +dirs = "6" +colored = "3" +dialoguer = "0.11" + diff --git a/crates/pm/src/cmd.rs b/crates/pm/src/cmd.rs new file mode 100644 index 00000000..edfa5b81 --- /dev/null +++ b/crates/pm/src/cmd.rs @@ -0,0 +1,965 @@ +use anyhow::{Context, Result, bail}; +use chrono::Utc; +use colored::Colorize; +use std::collections::BTreeMap; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +use crate::core::git; +use crate::core::pool::{self, AcquireResult, EvictionNeeded}; +use crate::core::state::{self, RepoEntry, State}; + +/// Options for resolving pool conflicts during `add` +#[derive(Debug, Default)] +pub struct AddConflictOpts { + /// Evict a specific project to free a pool slot (non-interactive) + pub evict: Option, + /// Grow the pool by one slot instead of evicting + pub grow_pool: bool, +} + +// ── init ──────────────────────────────────────────────────────────────── + +pub fn init(base: &Path) -> Result<()> { + std::fs::create_dir_all(State::repos_dir(base)).context("Failed to create repos directory")?; + std::fs::create_dir_all(State::pool_dir(base)).context("Failed to create pool directory")?; + + let state = State::new(base.to_path_buf()); + state.save()?; + + println!( + "{} Initialized pm workspace at {}", + "✓".green().bold(), + base.display() + ); + Ok(()) +} + +// ── new ───────────────────────────────────────────────────────────────── + +pub fn new(base: &Path, name: &str) -> Result<()> { + let mut state = State::load_or_err(base)?; + + // Handle orphaned state: project in state but dir is gone + if state.projects.contains_key(name) { + let project_dir = base.join(name); + if !project_dir.exists() { + println!( + "{} Cleaning up stale state for {}...", + "→".yellow().bold(), + name.bold() + ); + pool::release_project(&mut state, name); + state.projects.remove(name); + state.save()?; + } else { + bail!("Project '{}' already exists.", name); + } + } + + // Create project directory + let project_dir = base.join(name); + std::fs::create_dir_all(&project_dir) + .with_context(|| format!("Failed to create {}", project_dir.display()))?; + + state.projects.insert( + name.to_string(), + state::Project { + name: name.to_string(), + repos: BTreeMap::new(), + pinned: false, + created_at: Utc::now(), + last_activated: None, + }, + ); + state.save()?; + + println!("{} Created project {}", "✓".green().bold(), name.bold()); + println!(" {}", project_dir.display().to_string().dimmed()); + println!( + "\n cd into it and run {} to add repos.", + "pm add ".cyan() + ); + Ok(()) +} + +// ── add ───────────────────────────────────────────────────────────────── + +/// Resolve a repo spec into a git URL. +/// +/// Accepts: +/// - Full SSH URL: git@github.com:org/repo.git +/// - Full HTTPS URL: https://github.com/org/repo +/// - Shorthand: org/repo → git@github.com:org/repo.git +/// - Pool name: repo → already cloned, reuse +fn resolve_repo(state: &State, input: &str) -> (String, String, bool) { + // Already in pool by name? + if state.repos.contains_key(input) { + let entry = &state.repos[input]; + return (entry.name.clone(), entry.url.clone(), false); + } + + // Full URL? + if input.starts_with("git@") || input.starts_with("https://") || input.starts_with("http://") { + let name = state::repo_name_from_url(input); + let already = state.repos.contains_key(&name); + return (name, input.to_string(), !already); + } + + // Shorthand: org/repo → https://github.com/org/repo.git + if input.contains('/') && !input.contains(':') && !input.contains("//") { + let url = format!("https://github.com/{}.git", input); + let name = state::repo_name_from_url(&url); + let already = state.repos.contains_key(&name); + return (name, url, !already); + } + + // Last resort: treat as pool name that doesn't exist yet + (input.to_string(), input.to_string(), false) +} + +pub fn add( + base: &Path, + project: &str, + repo_input: &str, + branch_override: Option<&str>, + existing: Option, + worktree: bool, + conflict_opts: AddConflictOpts, +) -> Result<()> { + let mut state = State::load_or_err(base)?; + + // Ensure project exists (handle orphaned state) + if !state.projects.contains_key(project) { + // Maybe the dir exists but state doesn't — create it + let project_dir = base.join(project); + if project_dir.exists() { + state.projects.insert( + project.to_string(), + state::Project { + name: project.to_string(), + repos: BTreeMap::new(), + pinned: false, + created_at: Utc::now(), + last_activated: None, + }, + ); + state.save()?; + println!( + "{} Registered existing directory as project {}", + "→".yellow().bold(), + project.bold() + ); + } else { + bail!( + "Project '{}' not found. Run {} first.", + project, + format!("pm new {}", project).cyan() + ); + } + } + + // Handle --existing: either symlink directly or register for worktree use + if let Some(ref ext_path) = existing { + if worktree { + return add_existing_worktree( + base, + &mut state, + project, + repo_input, + branch_override, + ext_path, + &conflict_opts, + ); + } else { + return add_existing( + base, + &mut state, + project, + repo_input, + branch_override, + ext_path, + ); + } + } + + let (repo_name, url, needs_clone) = resolve_repo(&state, repo_input); + + // If this is an external repo, re-link it instead of doing worktree ops + if let Some(repo) = state.repos.get(&repo_name) + && repo.external + { + let ext_path = repo + .external_path + .clone() + .ok_or_else(|| anyhow::anyhow!("External repo '{}' has no path recorded", repo_name))?; + return add_existing( + base, + &mut state, + project, + &repo_name, + branch_override, + &ext_path, + ); + } + + // Clone into pool if needed + if needs_clone { + let bare_path = State::repos_dir(base).join(format!("{}.git", repo_name)); + println!("{} Cloning {}...", "→".blue().bold(), repo_name.bold()); + let actual_url = git::clone_bare(&url, &bare_path)?; + + state.repos.insert( + repo_name.clone(), + RepoEntry { + url: actual_url, + name: repo_name.clone(), + bare_path, + max_slots: pool::default_max_slots(), + external: false, + external_path: None, + }, + ); + state.save()?; + } else if !state.repos.contains_key(&repo_name) { + bail!( + "Repo '{}' not found in pool and doesn't look like a URL.\n\ + Try: pm add org/repo or pm add https://github.com/org/repo", + repo_input + ); + } + + // Determine branch + let branch = match branch_override { + Some(b) => b.to_string(), + None => { + let user = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "unknown".to_string()); + format!("{}/{}", user, project) + } + }; + + // Add repo to project state + state + .projects + .get_mut(project) + .unwrap() + .repos + .insert(repo_name.clone(), branch.clone()); + + // Acquire pool slot → worktree + print!(" {} {}:{} ", "→".dimmed(), repo_name, branch.dimmed()); + + let slot_idx = match pool::acquire_slot(&mut state, &repo_name, project, &branch)? { + AcquireResult::Acquired(idx) => idx, + AcquireResult::NeedsEviction(eviction) => { + println!(); // finish the pending print line + resolve_eviction(&mut state, eviction, project, &branch, &conflict_opts)? + } + }; + + let slot_path = state.pool.slots[&repo_name][slot_idx].path.clone(); + + // Symlink into project dir + let project_dir = base.join(project); + std::fs::create_dir_all(&project_dir)?; + + let link_path = project_dir.join(&repo_name); + if link_path.is_symlink() || link_path.exists() { + let _ = std::fs::remove_file(&link_path); + } + + #[cfg(unix)] + std::os::unix::fs::symlink(&slot_path, &link_path) + .with_context(|| format!("symlink {} → {}", link_path.display(), slot_path.display()))?; + + #[cfg(not(unix))] + std::os::windows::fs::symlink_dir(&slot_path, &link_path) + .with_context(|| format!("symlink {} → {}", link_path.display(), slot_path.display()))?; + + println!("{}", "✓".green()); + + // Update last_activated + state.projects.get_mut(project).unwrap().last_activated = Some(Utc::now()); + state.save()?; + + println!( + "\n{} {} ready at {}", + "✓".green().bold(), + repo_name.bold(), + link_path.display().to_string().cyan() + ); + Ok(()) +} + +/// Handle --existing: symlink an external checkout directly (no pool slot) +fn add_existing( + base: &Path, + state: &mut State, + project: &str, + repo_input: &str, + _branch_override: Option<&str>, + ext_path: &Path, +) -> Result<()> { + // Resolve to absolute/canonical path + let canonical = if ext_path.is_absolute() { + ext_path.to_path_buf() + } else { + std::env::current_dir()?.join(ext_path) + }; + let canonical = canonical.canonicalize().with_context(|| { + format!( + "Path '{}' does not exist or is not accessible", + ext_path.display() + ) + })?; + + // Validate it's a git repo + if !canonical.join(".git").exists() { + bail!( + "{} is not a git repository (no .git found).", + canonical.display() + ); + } + + // Derive repo name: use the input directly if it's a simple name, + // otherwise extract from URL/path + let repo_name = + if repo_input.contains('/') || repo_input.contains(':') || repo_input.contains("//") { + state::repo_name_from_url(repo_input) + } else { + repo_input.to_string() + }; + + // Get the remote URL for display/state + let url = std::process::Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(&canonical) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| format!("local:{}", canonical.display())); + + // Get current branch for display + let current_branch = std::process::Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(&canonical) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "(detached)".to_string()); + + // Register repo in state if not already there + if !state.repos.contains_key(&repo_name) { + println!( + "{} Registering {} as external repo (no clone needed)", + "→".blue().bold(), + repo_name.bold() + ); + state.repos.insert( + repo_name.clone(), + RepoEntry { + url, + name: repo_name.clone(), + bare_path: PathBuf::new(), // unused for external + max_slots: 1, // no pool slots needed + external: true, + external_path: Some(canonical.clone()), + }, + ); + } else { + let entry = state.repos.get(&repo_name).unwrap(); + if !entry.external { + bail!( + "Repo '{}' already exists as a pooled (non-external) repo.\n\ + Use a different name: pm add --existing {}", + repo_name, + ext_path.display() + ); + } + } + + // Track as "(external)" since pm doesn't manage the branch + state + .projects + .get_mut(project) + .unwrap() + .repos + .insert(repo_name.clone(), "(external)".to_string()); + + // Symlink the external path into the project dir + let project_dir = base.join(project); + std::fs::create_dir_all(&project_dir)?; + + let link_path = project_dir.join(&repo_name); + if link_path.is_symlink() || link_path.exists() { + let _ = std::fs::remove_file(&link_path); + } + + #[cfg(unix)] + std::os::unix::fs::symlink(&canonical, &link_path) + .with_context(|| format!("symlink {} → {}", link_path.display(), canonical.display()))?; + + #[cfg(not(unix))] + std::os::windows::fs::symlink_dir(&canonical, &link_path) + .with_context(|| format!("symlink {} → {}", link_path.display(), canonical.display()))?; + + state.projects.get_mut(project).unwrap().last_activated = Some(Utc::now()); + state.save()?; + + println!( + "{} {} linked as external repo (currently on {})", + "✓".green().bold(), + repo_name.bold(), + current_branch.cyan() + ); + println!( + " {} → {}", + link_path.display().to_string().dimmed(), + canonical.display().to_string().cyan() + ); + println!( + "\n {} Branch management is yours — pm won't checkout or switch branches.", + "ℹ".blue() + ); + Ok(()) +} + +// ── add --existing --worktree ──────────────────────────────────────────── + +/// Register an existing checkout as a pool repo (skip clone, use worktrees). +/// +/// Instead of bare-cloning, we point `bare_path` at the existing repo root. +/// `git worktree add` works from non-bare repos — it uses the same object store. +/// This gives you per-project branches without the multi-minute clone. +fn add_existing_worktree( + base: &Path, + state: &mut State, + project: &str, + repo_input: &str, + branch_override: Option<&str>, + ext_path: &Path, + conflict_opts: &AddConflictOpts, +) -> Result<()> { + // Resolve to absolute/canonical path + let canonical = if ext_path.is_absolute() { + ext_path.to_path_buf() + } else { + std::env::current_dir()?.join(ext_path) + }; + let canonical = canonical.canonicalize().with_context(|| { + format!( + "Path '{}' does not exist or is not accessible", + ext_path.display() + ) + })?; + + // Validate it's a git repo + if !canonical.join(".git").exists() { + bail!( + "{} is not a git repository (no .git found).", + canonical.display() + ); + } + + // Derive repo name + let repo_name = + if repo_input.contains('/') || repo_input.contains(':') || repo_input.contains("//") { + state::repo_name_from_url(repo_input) + } else { + repo_input.to_string() + }; + + // Get the remote URL for state + let url = std::process::Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(&canonical) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| format!("local:{}", canonical.display())); + + // Register repo in state — bare_path points to the existing repo root + // (git worktree add works from non-bare repos just fine) + if !state.repos.contains_key(&repo_name) { + println!( + "{} Registering {} from existing checkout (worktree mode, no clone)", + "→".blue().bold(), + repo_name.bold() + ); + state.repos.insert( + repo_name.clone(), + RepoEntry { + url, + name: repo_name.clone(), + bare_path: canonical.clone(), // the existing repo root + max_slots: pool::default_max_slots(), + external: false, + external_path: Some(canonical.clone()), + }, + ); + state.save()?; + } else { + let entry = state.repos.get(&repo_name).unwrap(); + if entry.external { + bail!( + "Repo '{}' is already registered as external (symlink-only).\n\ + Remove it first with `pm rm` on projects using it, then re-add with --worktree.", + repo_name, + ); + } + } + + // Determine branch + let branch = match branch_override { + Some(b) => b.to_string(), + None => { + let user = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "unknown".to_string()); + format!("{}/{}", user, project) + } + }; + + // Add repo to project state + state + .projects + .get_mut(project) + .unwrap() + .repos + .insert(repo_name.clone(), branch.clone()); + + // Acquire pool slot → worktree (normal pool machinery) + print!(" {} {}:{} ", "→".dimmed(), repo_name, branch.dimmed()); + + let slot_idx = match pool::acquire_slot(state, &repo_name, project, &branch)? { + AcquireResult::Acquired(idx) => idx, + AcquireResult::NeedsEviction(eviction) => { + println!(); + resolve_eviction(state, eviction, project, &branch, conflict_opts)? + } + }; + + let slot_path = state.pool.slots[&repo_name][slot_idx].path.clone(); + + // Symlink into project dir + let project_dir = base.join(project); + std::fs::create_dir_all(&project_dir)?; + + let link_path = project_dir.join(&repo_name); + if link_path.is_symlink() || link_path.exists() { + let _ = std::fs::remove_file(&link_path); + } + + #[cfg(unix)] + std::os::unix::fs::symlink(&slot_path, &link_path) + .with_context(|| format!("symlink {} → {}", link_path.display(), slot_path.display()))?; + + #[cfg(not(unix))] + std::os::windows::fs::symlink_dir(&slot_path, &link_path) + .with_context(|| format!("symlink {} → {}", link_path.display(), slot_path.display()))?; + + println!("{}", "✓".green()); + + state.projects.get_mut(project).unwrap().last_activated = Some(Utc::now()); + state.save()?; + + println!( + "\n{} {} ready at {} (worktree from {})", + "✓".green().bold(), + repo_name.bold(), + link_path.display().to_string().cyan(), + canonical.display().to_string().dimmed(), + ); + Ok(()) +} + +// ── eviction resolution ───────────────────────────────────────────────── + +/// Resolve a pool conflict — interactive menu, --evict, --grow-pool, or abort. +fn resolve_eviction( + state: &mut State, + eviction: EvictionNeeded, + project_name: &str, + branch: &str, + opts: &AddConflictOpts, +) -> Result { + let repo_name = &eviction.repo_name; + + // --grow-pool flag: just grow and go + if opts.grow_pool { + return pool::grow_and_acquire(state, repo_name, project_name, branch); + } + + // --evict flag: find the named project and evict it + if let Some(ref evict_target) = opts.evict { + let candidate = eviction + .candidates + .iter() + .find(|c| c.owner == *evict_target) + .ok_or_else(|| { + let available: Vec<&str> = eviction + .candidates + .iter() + .map(|c| c.owner.as_str()) + .collect(); + anyhow::anyhow!( + "Project '{}' is not using a slot for '{}'. Evictable projects: {}", + evict_target, + repo_name, + available.join(", ") + ) + })?; + + eprintln!( + " {} Evicting {} (branch: {}) from {} slot.", + "⚠".yellow().bold(), + candidate.owner.bold(), + candidate.branch.as_deref().unwrap_or("?").dimmed(), + repo_name.bold(), + ); + + return pool::execute_eviction( + state, + repo_name, + candidate.slot_index, + project_name, + branch, + ); + } + + // Non-interactive (agent/CI): abort with actionable guidance + if !std::io::stdin().is_terminal() { + let mut msg = format!( + "All {} pool slots for '{}' are in use. Cannot evict interactively.\n\n\ + Slots in use:\n", + eviction.current_max_slots, repo_name, + ); + for c in &eviction.candidates { + let age = format_age(c.last_used); + msg.push_str(&format!( + " • {} (branch: {}, last used {})\n", + c.owner, + c.branch.as_deref().unwrap_or("?"), + age, + )); + } + msg.push_str(&format!( + "\nTo resolve, re-run with one of:\n\ + \x20 pm add {} --evict evict a specific project\n\ + \x20 pm add {} --grow-pool add another slot\n", + repo_name, repo_name, + )); + bail!("{}", msg); + } + + // Interactive: show a menu + interactive_eviction_menu(state, eviction, project_name, branch) +} + +/// Interactive menu for choosing how to resolve a pool conflict. +fn interactive_eviction_menu( + state: &mut State, + eviction: EvictionNeeded, + project_name: &str, + branch: &str, +) -> Result { + use dialoguer::{Select, theme::ColorfulTheme}; + + let repo_name = &eviction.repo_name; + + eprintln!(); + eprintln!( + " {} All {} slots for {} are in use.", + "⚠".yellow().bold(), + eviction.current_max_slots, + repo_name.bold(), + ); + eprintln!(); + + // Build menu items + let mut items: Vec = Vec::new(); + + // Sort candidates by last_used (oldest first = most likely eviction target) + let mut candidates = eviction.candidates.clone(); + candidates.sort_by_key(|c| c.last_used); + + for c in &candidates { + let age = format_age(c.last_used); + items.push(format!( + "Evict {} (branch: {}, last used {})", + c.owner, + c.branch.as_deref().unwrap_or("?"), + age, + )); + } + + items.push(format!( + "Grow pool to {} slots", + eviction.current_max_slots + 1 + )); + items.push("Abort".to_string()); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("What would you like to do?") + .items(&items) + .default(0) + .interact()?; + + let evict_count = candidates.len(); + + if selection < evict_count { + // Evict selected project + let candidate = &candidates[selection]; + eprintln!(" {} Evicting {}...", "→".dimmed(), candidate.owner.bold(),); + pool::execute_eviction(state, repo_name, candidate.slot_index, project_name, branch) + } else if selection == evict_count { + // Grow pool + pool::grow_and_acquire(state, repo_name, project_name, branch) + } else { + // Abort + bail!("Aborted."); + } +} + +/// Format a timestamp as a human-readable relative age +fn format_age(dt: chrono::DateTime) -> String { + let now = Utc::now(); + let duration = now.signed_duration_since(dt); + + if duration.num_days() > 0 { + format!("{}d ago", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h ago", duration.num_hours()) + } else { + format!("{}m ago", duration.num_minutes().max(1)) + } +} + +// ── rm ────────────────────────────────────────────────────────────────── + +pub fn rm(base: &Path, name: &str) -> Result<()> { + let mut state = State::load_or_err(base)?; + + let project_dir = base.join(name); + + // Clean up worktree symlinks + pool slots if project is in state + if let Some(project) = state.projects.get(name).cloned() { + for repo_name in project.repos.keys() { + let link = project_dir.join(repo_name); + if link.is_symlink() || link.exists() { + let _ = std::fs::remove_file(&link); + } + } + pool::release_project(&mut state, name); + } + + // Remove project directory if it still exists + if project_dir.exists() { + std::fs::remove_dir_all(&project_dir) + .with_context(|| format!("Failed to remove {}", project_dir.display()))?; + } + + // Remove from state + state.projects.remove(name); + state.save()?; + + println!("{} Removed project {}", "✓".green().bold(), name.bold()); + Ok(()) +} + +// ── status ────────────────────────────────────────────────────────────── + +pub fn status(base: &Path) -> Result<()> { + let mut state = State::load_or_err(base)?; + + // First pass: detect and clean orphaned projects + let orphaned: Vec = state + .projects + .keys() + .filter(|name| !base.join(name).exists()) + .cloned() + .collect(); + + for name in &orphaned { + pool::release_project(&mut state, name); + state.projects.remove(name); + println!( + "{} Cleaned up orphaned project {}", + "→".yellow().bold(), + name.bold() + ); + } + if !orphaned.is_empty() { + state.save()?; + println!(); + } + + // Projects + println!("{}", "Projects".bold().underline()); + if state.projects.is_empty() { + println!(" {}", "(none)".dimmed()); + } + for (name, project) in &state.projects { + let repo_count = project.repos.len(); + let active = project + .last_activated + .map(|t| format!("active {}", t.format("%Y-%m-%d"))) + .unwrap_or_else(|| "never used".to_string()); + + println!( + " {} {} — {} repo(s), {}", + "•".blue(), + name.bold(), + repo_count, + active.dimmed() + ); + + for (repo, branch) in &project.repos { + let slot_info = state + .pool + .slots + .get(repo) + .and_then(|slots| { + slots + .iter() + .find(|s| s.owner.as_deref() == Some(name.as_str())) + }) + .map(|s| { + if s.path.exists() { + "✓".green().to_string() + } else { + "✗".red().to_string() + } + }) + .unwrap_or_else(|| "–".dimmed().to_string()); + + println!( + " {} {} {}:{}", + slot_info, + "→".dimmed(), + repo, + branch.dimmed() + ); + } + } + + println!(); + + // Pool repos + println!("{}", "Pool".bold().underline()); + if state.repos.is_empty() { + println!(" {}", "(none)".dimmed()); + } + for (name, repo) in &state.repos { + if repo.external { + let ext_path = repo + .external_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "?".to_string()); + println!( + " {} {} {} {}", + "•".blue(), + name.bold(), + "(external)".yellow(), + ext_path.dimmed() + ); + } else { + let slots = state.pool.slots.get(name); + let (used, total) = slots + .map(|s| (s.iter().filter(|sl| sl.owner.is_some()).count(), s.len())) + .unwrap_or((0, repo.max_slots)); + println!( + " {} {} {}/{} slots {}", + "•".blue(), + name.bold(), + used, + total, + repo.url.dimmed() + ); + } + } + + Ok(()) +} + +// ── cleanup ───────────────────────────────────────────────────────────── + +pub fn cleanup(base: &Path, stale_days: u64) -> Result<()> { + let state = State::load_or_err(base)?; + + if state.projects.is_empty() { + println!("{}", "No projects to analyze.".dimmed()); + return Ok(()); + } + + println!( + "{} Analyzing {} project(s)...\n", + "🔍".bold(), + state.projects.len() + ); + + let now = Utc::now(); + let mut found = 0; + + for (name, project) in &state.projects { + let mut reasons: Vec = Vec::new(); + + // Check if directory is missing + if !base.join(name).exists() { + reasons.push("directory missing (manually removed?)".to_string()); + } + + // Check staleness + if let Some(last) = project.last_activated { + let days = (now - last).num_days(); + if days > stale_days as i64 { + reasons.push(format!("inactive for {} days", days)); + } + } else { + reasons.push("never used".to_string()); + } + + // Check if branches are merged (skip external repos — no bare clone to inspect) + for (repo_name, branch) in &project.repos { + if let Some(repo) = state.repos.get(repo_name) { + if repo.external { + continue; + } + let default = git::default_branch(&repo.bare_path).unwrap_or("main".to_string()); + if branch != &default + && let Ok(true) = git::is_branch_merged(&repo.bare_path, branch, &default) + { + reasons.push(format!("{}:{} merged into {}", repo_name, branch, default)); + } + } + } + + if !reasons.is_empty() { + found += 1; + println!(" {} {}", "•".yellow(), name.bold()); + for reason in &reasons { + println!(" {} {}", "→".dimmed(), reason.dimmed()); + } + println!( + " {} {}", + "fix:".dimmed(), + format!("pm rm {}", name).cyan() + ); + println!(); + } + } + + if found == 0 { + println!("{} All projects look active.", "✓".green().bold()); + } else { + println!("📋 {} project(s) may be ready for cleanup.", found); + } + + Ok(()) +} diff --git a/crates/pm/src/core/git.rs b/crates/pm/src/core/git.rs new file mode 100644 index 00000000..a5028e24 --- /dev/null +++ b/crates/pm/src/core/git.rs @@ -0,0 +1,385 @@ +use anyhow::{Context, Result, bail}; +use std::path::Path; +use std::process::{Command, Stdio}; + +/// Try to clone bare. Returns Ok(()) on success, Err on failure. +/// Suppresses git output — we show our own messages. +fn try_clone_bare(url: &str, dest: &Path) -> Result<()> { + // Clean up dest if a previous attempt left it around + if dest.exists() { + let _ = std::fs::remove_dir_all(dest); + } + + let output = Command::new("git") + .args(["clone", "--bare", url]) + .arg(dest) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("Failed to run git")?; + + if !output.status.success() { + // Clean up partial clone + if dest.exists() { + let _ = std::fs::remove_dir_all(dest); + } + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("{}", stderr.trim()); + } + Ok(()) +} + +/// Convert between HTTPS and SSH URLs for GitHub +fn alternate_url(url: &str) -> Option { + // SSH → HTTPS + if let Some(rest) = url.strip_prefix("git@github.com:") { + return Some(format!("https://github.com/{}", rest)); + } + // HTTPS → SSH + if let Some(rest) = url + .strip_prefix("https://github.com/") + .or_else(|| url.strip_prefix("http://github.com/")) + { + return Some(format!("git@github.com:{}", rest)); + } + None +} + +/// Clone a bare repo, trying HTTPS first then SSH (or vice versa). +/// Returns the URL that actually worked. +pub fn clone_bare(url: &str, dest: &Path) -> Result { + // Try primary URL + match try_clone_bare(url, dest) { + Ok(()) => { + configure_bare(dest); + Ok(url.to_string()) + } + Err(_primary_err) => { + // Try alternate protocol + if let Some(alt) = alternate_url(url) + && let Ok(()) = try_clone_bare(&alt, dest) + { + configure_bare(dest); + return Ok(alt); + } + // Both failed — give a clean error + bail!( + "Could not clone {}. Check the URL and your access permissions.", + url + ); + } + } +} + +/// Configure a bare clone for proper fetching +fn configure_bare(dest: &Path) { + let _ = Command::new("git") + .args([ + "config", + "remote.origin.fetch", + "+refs/heads/*:refs/remotes/origin/*", + ]) + .current_dir(dest) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + let _ = Command::new("git") + .args(["fetch", "--all"]) + .current_dir(dest) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +/// Create a worktree from a bare repo +pub fn add_worktree(bare_path: &Path, worktree_path: &Path, branch: &str) -> Result<()> { + let _ = ensure_fetched(bare_path); + + // Try checking out existing branch + let output = Command::new("git") + .args(["worktree", "add", "--force"]) + .arg(worktree_path) + .arg(branch) + .current_dir(bare_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .context("Failed to run git worktree add")?; + + if output.status.success() { + return Ok(()); + } + + // Branch might not exist — try creating from default branch + let default = default_branch(bare_path).unwrap_or_else(|_| "main".to_string()); + let output = Command::new("git") + .args(["worktree", "add", "--force", "-b", branch]) + .arg(worktree_path) + .arg(format!("origin/{}", default)) + .current_dir(bare_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .context("Failed to run git worktree add")?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "Failed to create worktree for branch '{}': {}", + branch, + stderr.trim() + ); +} + +/// Remove a worktree +#[allow(dead_code)] +pub fn remove_worktree(bare_path: &Path, worktree_path: &Path) -> Result<()> { + let output = Command::new("git") + .args(["worktree", "remove", "--force"]) + .arg(worktree_path) + .current_dir(bare_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .context("Failed to run git worktree remove")?; + if !output.status.success() { + let _ = Command::new("git") + .args(["worktree", "prune"]) + .current_dir(bare_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + Ok(()) +} + +/// Checkout a branch in an existing worktree directory +pub fn checkout(worktree_path: &Path, branch: &str) -> Result<()> { + let _ = Command::new("git") + .args(["fetch", "--all", "--prune"]) + .current_dir(worktree_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + // 1. Try checking out existing local branch + let output = Command::new("git") + .args(["checkout", branch]) + .current_dir(worktree_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .context("Failed to run git checkout")?; + + if output.status.success() { + return Ok(()); + } + + // 2. Try creating as tracking branch from remote + let output = Command::new("git") + .args(["checkout", "-b", branch, &format!("origin/{}", branch)]) + .current_dir(worktree_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .context("Failed to create tracking branch")?; + + if output.status.success() { + return Ok(()); + } + + // 3. Branch doesn't exist anywhere — create from default branch (like add_worktree does) + let default = + default_branch_from_worktree(worktree_path).unwrap_or_else(|_| "main".to_string()); + let output = Command::new("git") + .args(["checkout", "-b", branch, &format!("origin/{}", default)]) + .current_dir(worktree_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .context("Failed to create branch from default")?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Failed to checkout '{}': {}", branch, stderr.trim()); +} + +/// Get the default branch from a worktree (resolves via remote HEAD) +fn default_branch_from_worktree(worktree_path: &Path) -> Result { + let output = Command::new("git") + .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) + .current_dir(worktree_path) + .stderr(Stdio::null()) + .output() + .context("Failed to get default branch")?; + + if output.status.success() { + let refname = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if let Some(branch) = refname.strip_prefix("refs/remotes/origin/") { + return Ok(branch.to_string()); + } + } + + for candidate in &["main", "master"] { + let output = Command::new("git") + .args(["rev-parse", "--verify", &format!("origin/{}", candidate)]) + .current_dir(worktree_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output(); + if let Ok(o) = output + && o.status.success() + { + return Ok(candidate.to_string()); + } + } + + Ok("main".to_string()) +} + +/// Get the default branch of a bare repo (usually main or master) +pub fn default_branch(bare_path: &Path) -> Result { + let _ = ensure_fetched(bare_path); + + let output = Command::new("git") + .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) + .current_dir(bare_path) + .stderr(Stdio::null()) + .output() + .context("Failed to get default branch")?; + + if output.status.success() { + let refname = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // refs/remotes/origin/main -> main + if let Some(branch) = refname.strip_prefix("refs/remotes/origin/") { + return Ok(branch.to_string()); + } + } + + // Fallback: try main, then master + for candidate in &["main", "master"] { + let output = Command::new("git") + .args(["rev-parse", "--verify", &format!("origin/{}", candidate)]) + .current_dir(bare_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output(); + if let Ok(o) = output + && o.status.success() + { + return Ok(candidate.to_string()); + } + } + + Ok("main".to_string()) +} + +/// Check if a branch has been merged into the default branch. +/// +/// Handles three cases: +/// 1. Normal merge: branch ref exists remotely and is an ancestor of default +/// 2. Squash merge: remote branch deleted after squash-merge (branch gone = likely merged) +/// 3. Local-only: branch exists locally but not remotely — check if local tip is ancestor +pub fn is_branch_merged(bare_path: &Path, branch: &str, default: &str) -> Result { + let _ = ensure_fetched(bare_path); + + // Case 1: Check if remote branch ref is merged via `git branch -r --merged` + let output = Command::new("git") + .args(["branch", "-r", "--merged", &format!("origin/{}", default)]) + .current_dir(bare_path) + .stderr(Stdio::null()) + .output() + .context("Failed to check merged branches")?; + + if output.status.success() { + let merged = String::from_utf8_lossy(&output.stdout); + let target = format!("origin/{}", branch); + if merged.lines().any(|line| line.trim() == target) { + return Ok(true); + } + } + + // Case 2: Remote branch no longer exists — it was likely deleted after merge + let remote_exists = Command::new("git") + .args(["rev-parse", "--verify", &format!("origin/{}", branch)]) + .current_dir(bare_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !remote_exists { + return Ok(true); + } + + // Case 3: Remote branch exists but wasn't in --merged list. + // Check if it's a direct ancestor (handles cases where --merged is stale). + let is_ancestor = Command::new("git") + .args([ + "merge-base", + "--is-ancestor", + &format!("origin/{}", branch), + &format!("origin/{}", default), + ]) + .current_dir(bare_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + Ok(is_ancestor) +} + +/// Get the last commit date on a branch +#[allow(dead_code)] +pub fn last_commit_date(bare_path: &Path, branch: &str) -> Result> { + let output = Command::new("git") + .args(["log", "-1", "--format=%ci", &format!("origin/{}", branch)]) + .current_dir(bare_path) + .stderr(Stdio::null()) + .output() + .context("Failed to get last commit date")?; + + if output.status.success() { + let date = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !date.is_empty() { + return Ok(Some(date)); + } + } + Ok(None) +} + +/// Ensure a bare repo has the fetch refspec configured, then fetch +fn ensure_fetched(bare_path: &Path) -> Result<()> { + let _ = Command::new("git") + .args([ + "config", + "remote.origin.fetch", + "+refs/heads/*:refs/remotes/origin/*", + ]) + .current_dir(bare_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + let output = Command::new("git") + .args(["fetch", "--all", "--prune"]) + .current_dir(bare_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .context("Failed to fetch")?; + if !output.status.success() { + bail!("git fetch failed"); + } + Ok(()) +} diff --git a/crates/pm/src/core/mod.rs b/crates/pm/src/core/mod.rs new file mode 100644 index 00000000..aa695fd0 --- /dev/null +++ b/crates/pm/src/core/mod.rs @@ -0,0 +1,3 @@ +pub mod git; +pub mod pool; +pub mod state; diff --git a/crates/pm/src/core/pool.rs b/crates/pm/src/core/pool.rs new file mode 100644 index 00000000..cb8c6067 --- /dev/null +++ b/crates/pm/src/core/pool.rs @@ -0,0 +1,303 @@ +use anyhow::{Context, Result, bail}; +use chrono::Utc; + +use super::git; +use super::state::{Slot, State}; + +const DEFAULT_MAX_SLOTS: usize = 2; + +/// Ensure a repo has its pool slots initialized in state +pub fn ensure_slots(state: &mut State, repo_name: &str) -> Result<()> { + let repo = state + .repos + .get(repo_name) + .with_context(|| format!("Repo '{}' not found. Run `pm repo add` first.", repo_name))? + .clone(); + + let max_slots = repo.max_slots; + let pool_dir = State::pool_dir(&state.root); + + let slots = state.pool.slots.entry(repo_name.to_string()).or_default(); + + while slots.len() < max_slots { + let index = slots.len(); + let slot_path = pool_dir.join(format!("{}--{}", repo_name, index)); + slots.push(Slot { + index, + path: slot_path, + owner: None, + branch: None, + last_used: Utc::now(), + }); + } + + Ok(()) +} + +/// Info about a slot that could be evicted +#[derive(Debug, Clone)] +pub struct EvictionCandidate { + pub slot_index: usize, + pub owner: String, + pub branch: Option, + pub last_used: chrono::DateTime, +} + +/// Returned when all slots are full and eviction is needed +#[derive(Debug)] +pub struct EvictionNeeded { + pub repo_name: String, + pub candidates: Vec, + pub current_max_slots: usize, +} + +/// Result of trying to acquire a slot +pub enum AcquireResult { + /// Slot acquired successfully + Acquired(usize), + /// All slots full — caller must decide what to do + NeedsEviction(EvictionNeeded), +} + +/// Acquire a pool slot for a project+repo+branch. +/// +/// Strategy: +/// 1. If this project already owns a slot for this repo, reuse it +/// 2. If there's a free (unowned) slot, take it +/// 3. Return eviction candidates for the caller to decide +/// +/// Returns `AcquireResult`. +pub fn acquire_slot( + state: &mut State, + repo_name: &str, + project_name: &str, + branch: &str, +) -> Result { + ensure_slots(state, repo_name)?; + + let slots = state.pool.slots.get_mut(repo_name).unwrap(); + + // 1. Already owned by this project? + if let Some(slot) = slots + .iter_mut() + .find(|s| s.owner.as_deref() == Some(project_name)) + { + let idx = slot.index; + let needs_checkout = slot.branch.as_deref() != Some(branch); + slot.branch = Some(branch.to_string()); + slot.last_used = Utc::now(); + + if needs_checkout { + let repo = state.repos.get(repo_name).unwrap(); + if slot.path.exists() { + git::checkout(&slot.path, branch)?; + } else { + git::add_worktree(&repo.bare_path, &slot.path, branch)?; + } + } + return Ok(AcquireResult::Acquired(idx)); + } + + // 2. Free slot? + if let Some(slot) = slots.iter_mut().find(|s| s.owner.is_none()) { + let idx = slot.index; + slot.owner = Some(project_name.to_string()); + slot.branch = Some(branch.to_string()); + slot.last_used = Utc::now(); + + let repo = state.repos.get(repo_name).unwrap(); + if slot.path.exists() { + git::checkout(&slot.path, branch)?; + } else { + git::add_worktree(&repo.bare_path, &slot.path, branch)?; + } + return Ok(AcquireResult::Acquired(idx)); + } + + // 3. All slots full — gather eviction candidates (non-pinned) + let pinned_projects: Vec = state + .projects + .values() + .filter(|p| p.pinned) + .map(|p| p.name.clone()) + .collect(); + + let slots = state.pool.slots.get(repo_name).unwrap(); + let candidates: Vec = slots + .iter() + .filter(|s| { + s.owner + .as_ref() + .map(|o| !pinned_projects.contains(o)) + .unwrap_or(true) + }) + .map(|s| EvictionCandidate { + slot_index: s.index, + owner: s.owner.clone().unwrap_or_default(), + branch: s.branch.clone(), + last_used: s.last_used, + }) + .collect(); + + if candidates.is_empty() { + bail!( + "No available pool slots for '{}'. All {} slots are pinned.\n\ + Increase pool size with: pm add {} --grow-pool\n\ + Or unpin a project first.", + repo_name, + slots.len(), + repo_name, + ); + } + + let current_max_slots = state + .repos + .get(repo_name) + .map(|r| r.max_slots) + .unwrap_or(DEFAULT_MAX_SLOTS); + + Ok(AcquireResult::NeedsEviction(EvictionNeeded { + repo_name: repo_name.to_string(), + candidates, + current_max_slots, + })) +} + +/// Execute an eviction: take over a specific slot for a new project+branch. +/// +/// This performs the git checkout first, then cleans up the evicted project's state. +pub fn execute_eviction( + state: &mut State, + repo_name: &str, + evict_idx: usize, + project_name: &str, + branch: &str, +) -> Result { + let slots = state.pool.slots.get(repo_name).unwrap(); + let evicted_owner = slots[evict_idx].owner.clone(); + + // Attempt git checkout/worktree BEFORE removing anything. + // If git fails, the evicted project's state is untouched. + let slot_path = slots[evict_idx].path.clone(); + let repo = state.repos.get(repo_name).unwrap().clone(); + if slot_path.exists() { + git::checkout(&slot_path, branch)?; + } else { + git::add_worktree(&repo.bare_path, &slot_path, branch)?; + } + + // Git succeeded — now safe to update symlinks and state + let slots = state.pool.slots.get_mut(repo_name).unwrap(); + if let Some(ref owner) = evicted_owner { + let project_dir = State::projects_dir(&state.root).join(owner); + let link = project_dir.join(repo_name); + if link.exists() || link.is_symlink() { + let _ = std::fs::remove_file(&link); + } + // Remove the repo from the evicted project's repo map + if let Some(evicted_project) = state.projects.get_mut(owner.as_str()) { + evicted_project.repos.remove(repo_name); + } + } + + let slot = &mut slots[evict_idx]; + slot.owner = Some(project_name.to_string()); + slot.branch = Some(branch.to_string()); + slot.last_used = Utc::now(); + + Ok(evict_idx) +} + +/// Grow the pool for a repo by 1 slot, then acquire it for the given project+branch. +pub fn grow_and_acquire( + state: &mut State, + repo_name: &str, + project_name: &str, + branch: &str, +) -> Result { + // Bump max_slots + let repo = state + .repos + .get_mut(repo_name) + .with_context(|| format!("Repo '{}' not found", repo_name))?; + repo.max_slots += 1; + let new_max = repo.max_slots; + + // Re-ensure slots so the new one is created + ensure_slots(state, repo_name)?; + + let slots = state.pool.slots.get_mut(repo_name).unwrap(); + let slot = slots + .iter_mut() + .find(|s| s.owner.is_none()) + .expect("Just grew pool, should have a free slot"); + + let idx = slot.index; + slot.owner = Some(project_name.to_string()); + slot.branch = Some(branch.to_string()); + slot.last_used = Utc::now(); + + let repo = state.repos.get(repo_name).unwrap(); + if slot.path.exists() { + git::checkout(&slot.path, branch)?; + } else { + git::add_worktree(&repo.bare_path, &slot.path, branch)?; + } + + use colored::Colorize; + eprintln!( + " {} Pool for {} grown to {} slots.", + "↑".green().bold(), + repo_name.bold(), + new_max, + ); + + Ok(idx) +} + +/// Release all slots owned by a project +pub fn release_project(state: &mut State, project_name: &str) { + for slots in state.pool.slots.values_mut() { + for slot in slots.iter_mut() { + if slot.owner.as_deref() == Some(project_name) { + slot.owner = None; + } + } + } +} + +/// Get the default max slots for a new repo +pub fn default_max_slots() -> usize { + DEFAULT_MAX_SLOTS +} + +/// Remove a worktree slot from disk and prune +#[allow(dead_code)] +pub fn remove_slot_worktree(state: &State, repo_name: &str, slot: &Slot) -> Result<()> { + if slot.path.exists() { + if let Some(repo) = state.repos.get(repo_name) { + git::remove_worktree(&repo.bare_path, &slot.path)?; + } + // If git worktree remove didn't clean it up, force remove + if slot.path.exists() { + std::fs::remove_dir_all(&slot.path) + .with_context(|| format!("Failed to remove {}", slot.path.display()))?; + } + } + Ok(()) +} + +/// Resolve the worktree path for a given project's repo +#[allow(dead_code)] +pub fn resolve_slot_path( + state: &State, + repo_name: &str, + project_name: &str, +) -> Option { + state.pool.slots.get(repo_name).and_then(|slots| { + slots + .iter() + .find(|s| s.owner.as_deref() == Some(project_name)) + .map(|s| s.path.clone()) + }) +} diff --git a/crates/pm/src/core/state.rs b/crates/pm/src/core/state.rs new file mode 100644 index 00000000..79f10685 --- /dev/null +++ b/crates/pm/src/core/state.rs @@ -0,0 +1,160 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Root directory name for pm state +const PM_DIR: &str = ".pm"; +const STATE_FILE: &str = "state.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct State { + pub root: PathBuf, + pub repos: BTreeMap, + pub projects: BTreeMap, + pub pool: PoolState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoEntry { + /// Remote URL + pub url: String, + /// Short name derived from URL (e.g. "repo1") + pub name: String, + /// Path to the bare clone (unused for external repos) + pub bare_path: PathBuf, + /// Max pool slots for this repo + pub max_slots: usize, + /// If true, this repo points to an existing local checkout (no bare clone, no worktree management) + #[serde(default)] + pub external: bool, + /// Path to the existing checkout (only set when external=true) + #[serde(default)] + pub external_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + pub name: String, + /// repo_name -> branch + pub repos: BTreeMap, + pub pinned: bool, + pub created_at: DateTime, + pub last_activated: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PoolState { + /// repo_name -> vec of slots + pub slots: BTreeMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Slot { + pub index: usize, + /// Worktree directory path + pub path: PathBuf, + /// Which project currently owns this slot (if any) + pub owner: Option, + /// What branch is checked out + pub branch: Option, + /// Last time this slot was activated + pub last_used: DateTime, +} + +impl State { + pub fn repos_dir(base: &Path) -> PathBuf { + base.join(PM_DIR).join("repos") + } + + pub fn pool_dir(base: &Path) -> PathBuf { + base.join(PM_DIR).join("pool") + } + + pub fn projects_dir(base: &Path) -> PathBuf { + base.to_path_buf() + } + + pub fn state_file(base: &Path) -> PathBuf { + base.join(PM_DIR).join(STATE_FILE) + } + + /// Load state from disk, or return None if not initialized + pub fn load(base: &Path) -> Result> { + let path = Self::state_file(base); + if !path.exists() { + return Ok(None); + } + let data = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read state from {}", path.display()))?; + let state: State = + serde_json::from_str(&data).with_context(|| "Failed to parse state file")?; + Ok(Some(state)) + } + + /// Load state, error if not initialized + pub fn load_or_err(base: &Path) -> Result { + Self::load(base)?.with_context(|| { + format!( + "pm is not initialized in {}. Run `pm init` first.", + base.display() + ) + }) + } + + /// Save state to disk + pub fn save(&self) -> Result<()> { + let path = Self::state_file(&self.root); + let data = serde_json::to_string_pretty(self).context("Failed to serialize state")?; + std::fs::write(&path, data) + .with_context(|| format!("Failed to write state to {}", path.display()))?; + Ok(()) + } + + /// Create a fresh state for a new init + pub fn new(root: PathBuf) -> Self { + Self { + root, + repos: BTreeMap::new(), + projects: BTreeMap::new(), + pool: PoolState { + slots: BTreeMap::new(), + }, + } + } +} + +/// Derive a short repo name from a URL. +/// e.g. "git@github.com:org/repo.git" -> "repo" +/// e.g. "https://github.com/org/repo" -> "repo" +pub fn repo_name_from_url(url: &str) -> String { + let s = url.trim_end_matches('/').trim_end_matches(".git"); + s.rsplit('/') + .next() + .or_else(|| s.rsplit(':').next()) + .unwrap_or(s) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_repo_name_from_url() { + assert_eq!( + repo_name_from_url("git@github.com:org/myrepo.git"), + "myrepo" + ); + assert_eq!( + repo_name_from_url("https://github.com/org/myrepo"), + "myrepo" + ); + assert_eq!( + repo_name_from_url("https://github.com/org/myrepo.git"), + "myrepo" + ); + assert_eq!(repo_name_from_url("git@github.com:org/myrepo"), "myrepo"); + } +} diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs new file mode 100644 index 00000000..87eb1e69 --- /dev/null +++ b/crates/pm/src/main.rs @@ -0,0 +1,163 @@ +mod cmd; +mod core; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command( + name = "pm", + about = "Project manager for multi-repo workspaces", + version, + styles = styling(), +)] +struct Cli { + /// Root directory for pm workspace (default: auto-detected) + #[arg(long, global = true)] + root: Option, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Create a new project + New { + /// Project name + name: String, + }, + + /// Add a repo to the current project + Add { + /// Repo: owner/name shorthand, full URL, or pool repo name + repo: String, + + /// Explicit branch (default: $USER/$PROJECT) + #[arg(long)] + branch: Option, + + /// Target project (default: inferred from cwd) + #[arg(long)] + project: Option, + + /// Use an existing local checkout instead of cloning (for heavy repos). + /// Default: symlinks directly (pm won't manage branches). + /// Combine with --worktree to get per-project branches without cloning. + #[arg(long)] + existing: Option, + + /// With --existing: create worktrees from the existing repo so pm manages branches. + /// Without this, --existing just symlinks the checkout directly. + #[arg(long, requires = "existing")] + worktree: bool, + + /// Evict a specific project to free a pool slot (non-interactive) + #[arg(long)] + evict: Option, + + /// Grow the pool by one slot instead of evicting + #[arg(long, conflicts_with = "evict")] + grow_pool: bool, + }, + + /// Remove a project (also cleans up stale state from manual rm) + Rm { + /// Project name + name: String, + }, + + /// Show status of projects and repos + Status, + + /// Analyze projects and recommend cleanup + Cleanup { + /// Days of inactivity before a project is considered stale + #[arg(long, default_value = "14")] + stale_days: u64, + }, +} + +fn styling() -> clap::builder::Styles { + clap::builder::Styles::styled() + .header(clap::builder::styling::AnsiColor::Green.on_default().bold()) + .usage(clap::builder::styling::AnsiColor::Green.on_default().bold()) + .literal(clap::builder::styling::AnsiColor::Cyan.on_default().bold()) + .placeholder(clap::builder::styling::AnsiColor::Cyan.on_default()) +} + +/// Walk up from cwd looking for .pm/state.json +fn resolve_root(cli_root: Option) -> Result { + if let Some(root) = cli_root { + return Ok(root); + } + + let cwd = std::env::current_dir()?; + let mut dir = cwd.as_path(); + loop { + if dir.join(".pm").join("state.json").exists() { + return Ok(dir.to_path_buf()); + } + match dir.parent() { + Some(parent) => dir = parent, + None => break, + } + } + + Ok(cwd) +} + +/// Infer which project we're in from cwd relative to workspace root +fn infer_project(base: &std::path::Path) -> Option { + let cwd = std::env::current_dir().ok()?; + let relative = cwd.strip_prefix(base).ok()?; + let first = relative.components().next()?; + let name = first.as_os_str().to_string_lossy().to_string(); + // Don't match .pm + if name.starts_with('.') { + return None; + } + Some(name) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let base = resolve_root(cli.root)?; + + // Auto-init if no workspace exists + if !base.join(".pm").join("state.json").exists() { + cmd::init(&base)?; + } + + match cli.command { + Commands::New { name } => cmd::new(&base, &name), + Commands::Add { + repo, + branch, + project, + existing, + worktree, + evict, + grow_pool, + } => { + let project_name = project.or_else(|| infer_project(&base)).ok_or_else(|| { + anyhow::anyhow!( + "Can't infer project from cwd. Use --project or cd into a project dir." + ) + })?; + cmd::add( + &base, + &project_name, + &repo, + branch.as_deref(), + existing, + worktree, + cmd::AddConflictOpts { evict, grow_pool }, + ) + } + Commands::Rm { name } => cmd::rm(&base, &name), + Commands::Status => cmd::status(&base), + Commands::Cleanup { stale_days } => cmd::cleanup(&base, stale_days), + } +}