use anyhow::{Result, bail};
use cmd_lib::run_fun;
use remote::{ListRepos, Remote};
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use tracing::{debug, info, warn};
pub mod remote;
#[cfg(feature = "tui")]
pub mod tui;
#[derive(Debug, Eq, PartialEq)]
pub struct RepoPattern {
pub provider: Option<String>,
pub path: String,
}
impl FromStr for RepoPattern {
type Err = Box<dyn Error>;
fn from_str(path: &str) -> std::prelude::v1::Result<Self, Self::Err> {
let parts: Vec<&str> = path.splitn(2, '/').collect();
if parts.len() == 2 {
let first = parts[0];
let rest = parts[1];
if first.contains('.') {
Ok(Self {
provider: Some(first.to_string()),
path: rest.to_string(),
})
} else {
Ok(Self {
provider: None,
path: path.to_string(),
})
}
} else {
Ok(Self {
provider: None,
path: path.to_string(),
})
}
}
}
impl RepoPattern {
pub fn provider_and_path(&self) -> Option<(&str, &str)> {
self.provider
.as_ref()
.map(|p| (p.as_str(), self.path.as_str()))
}
pub fn full_path(&self) -> String {
match &self.provider {
Some(provider) => format!("{}/{}", provider, self.path),
None => self.path.clone(),
}
}
pub fn matches(&self, test_path: &str) -> bool {
let full = self.full_path();
if test_path == full {
return true;
}
if test_path.ends_with(&full) {
return true;
}
if test_path.contains(&self.path) {
return true;
}
false
}
}
pub fn find_git_repositories(path: &str) -> Result<Vec<PathBuf>> {
debug!(path = %path, "Recursively searching for git repositories");
let mut found: Vec<PathBuf> = Vec::new();
let path_buf = PathBuf::from(path);
if path_buf.join(".git").exists() {
found.push(path_buf);
return Ok(found); }
if let Ok(entries) = std::fs::read_dir(&path_buf) {
for entry in entries.filter_map(|e| e.ok()) {
let entry_path = entry.path();
if let Some(name) = entry_path.file_name() {
let name_str = name.to_string_lossy();
if name_str == ".git" {
continue;
}
}
if entry_path.is_dir() {
match find_git_repositories(&entry_path.to_string_lossy()) {
Ok(mut repos) => found.append(&mut repos),
Err(e) => {
debug!("Skipping {}: {}", entry_path.display(), e);
}
}
}
}
}
Ok(found)
}
pub fn check_uncommitted_changes(repo_path: &Path) -> Result<bool> {
let repo = match gix::open(repo_path) {
Ok(r) => r,
Err(e) => {
warn!(
"Failed to open repository at {}: {}",
repo_path.display(),
e
);
return Ok(false); }
};
let is_dirty = match repo.is_dirty() {
Ok(dirty) => dirty,
Err(e) => {
warn!(
"Failed to check if repository is dirty at {}: {}",
repo_path.display(),
e
);
return Ok(false);
}
};
if is_dirty {
debug!(
"Repository has uncommitted changes: {}",
repo_path.display()
);
return Ok(true);
}
let platform = match repo.status(gix::progress::Discard) {
Ok(p) => p,
Err(e) => {
warn!(
"Failed to create status platform at {}: {}",
repo_path.display(),
e
);
return Ok(false);
}
};
match platform
.untracked_files(gix::status::UntrackedFiles::Files)
.into_index_worktree_iter(Vec::new())
{
Ok(mut iter) => {
for entry in iter.by_ref().flatten() {
if matches!(
entry,
gix::status::index_worktree::iter::Item::DirectoryContents { .. }
) {
debug!("Repository has untracked files: {}", repo_path.display());
return Ok(true);
}
}
}
Err(e) => {
warn!(
"Failed to check for untracked files at {}: {}",
repo_path.display(),
e
);
}
}
Ok(false)
}
#[derive(Debug, Default)]
pub struct RepoStatus {
pub has_changes: bool,
pub has_unpushed: bool,
pub has_commits: bool,
}
pub fn check_repo_status(repo_path: &Path) -> Result<RepoStatus> {
let mut status = RepoStatus::default();
let repo = match gix::open(repo_path) {
Ok(r) => r,
Err(e) => {
warn!(
"Failed to open repository at {}: {}",
repo_path.display(),
e
);
return Ok(status);
}
};
match repo.head() {
Ok(head) => {
match head.try_into_referent() {
Some(head_ref) => {
status.has_commits = true;
let local_branch = head_ref.name();
let remote_ref_name = match repo
.branch_remote_ref_name(local_branch, gix::remote::Direction::Fetch)
{
Some(Ok(name)) => name,
Some(Err(e)) => {
debug!("Failed to get remote ref: {}", e);
return Ok(status); }
None => {
debug!("No upstream branch configured");
return Ok(status); }
};
match repo.find_reference(remote_ref_name.as_ref()) {
Ok(remote_ref) => {
let local_commit = match head_ref.id().object() {
Ok(obj) => obj.id,
Err(e) => {
warn!("Failed to get local commit: {}", e);
return Ok(status);
}
};
let remote_commit = match remote_ref.id().object() {
Ok(obj) => obj.id,
Err(e) => {
warn!("Failed to get remote commit: {}", e);
return Ok(status);
}
};
if local_commit != remote_commit {
status.has_unpushed = true;
}
}
Err(_) => {
debug!("Remote ref not found, assuming no unpushed commits");
}
}
}
None => {
status.has_commits = false;
}
}
}
Err(_) => {
status.has_commits = false;
}
}
match repo.is_dirty() {
Ok(dirty) if dirty => {
status.has_changes = true;
return Ok(status);
}
Err(e) => {
warn!(
"Failed to check if repository is dirty at {}: {}",
repo_path.display(),
e
);
return Ok(status);
}
_ => {}
}
let platform = match repo.status(gix::progress::Discard) {
Ok(p) => p,
Err(e) => {
warn!(
"Failed to create status platform at {}: {}",
repo_path.display(),
e
);
return Ok(status);
}
};
match platform
.untracked_files(gix::status::UntrackedFiles::Files)
.into_index_worktree_iter(Vec::new())
{
Ok(mut iter) => {
for entry in iter.by_ref().flatten() {
if matches!(
entry,
gix::status::index_worktree::iter::Item::DirectoryContents { .. }
) {
status.has_changes = true;
break;
}
}
}
Err(e) => {
warn!(
"Failed to check for untracked files at {}: {}",
repo_path.display(),
e
);
}
}
Ok(status)
}
pub fn check_has_commits(repo_path: &Path) -> Result<bool> {
Ok(check_repo_status(repo_path)?.has_commits)
}
pub fn check_unpushed_commits(repo_path: &Path) -> Result<bool> {
let repo = match gix::open(repo_path) {
Ok(r) => r,
Err(e) => {
warn!(
"Failed to open repository at {}: {}",
repo_path.display(),
e
);
return Ok(false);
}
};
let head = repo.head()?;
let head_ref = match head.try_into_referent() {
Some(r) => r,
None => {
debug!("HEAD is detached, no tracking branch");
return Ok(false); }
};
let local_branch = head_ref.name();
let remote_ref_name =
match repo.branch_remote_ref_name(local_branch, gix::remote::Direction::Fetch) {
Some(Ok(name)) => name,
Some(Err(e)) => {
debug!("Error getting remote ref name: {}", e);
return Ok(false);
}
None => {
debug!("No upstream branch configured");
return Ok(false); }
};
let remote_ref = match repo.find_reference(remote_ref_name.as_ref()) {
Ok(r) => r,
Err(_) => {
debug!("Remote reference not found: {:?}", remote_ref_name);
return Ok(false); }
};
let local_commit = head_ref.id();
let remote_commit = remote_ref.id();
Ok(local_commit != remote_commit)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Workspace {
#[serde(skip)]
pub path: String,
#[serde(default)]
pub remotes: Vec<Remote>,
#[serde(flatten)]
pub library: Option<Library>,
}
impl Default for Workspace {
fn default() -> Self {
let home = home::home_dir().expect("the home directory exists");
Self {
path: std::env::current_dir()
.ok()
.unwrap_or_else(|| home.join("workspace"))
.display()
.to_string(),
remotes: vec![],
library: Some(Library {
path: home.join(".workset").display().to_string(),
}),
}
}
}
impl Workspace {
pub fn load() -> Result<Option<Self>> {
let mut workspace_root = std::env::current_dir()?;
let config_path = loop {
if workspace_root.join(".workset.toml").exists() {
break workspace_root.join(".workset.toml");
}
match workspace_root.parent() {
Some(parent) => workspace_root = parent.to_path_buf(),
None => return Ok(None),
}
};
debug!(config_path = %config_path.display(), "Loading workspace configuration");
let config_content = std::fs::read_to_string(&config_path)
.map_err(|e| anyhow::anyhow!("Failed to read workspace config: {}", e))?;
let mut workspace: Workspace = toml::from_str(&config_content)
.map_err(|e| anyhow::anyhow!("Failed to parse workspace config: {}", e))?;
workspace.path = workspace_root.display().to_string();
workspace.validate()?;
debug!(workspace = ?workspace, "Loaded workspace configuration");
if let Some(library) = workspace.library.as_ref() {
std::fs::create_dir_all(&library.path)
.map_err(|e| anyhow::anyhow!("Failed to create library directory: {}", e))?;
}
Ok(Some(workspace))
}
fn validate(&self) -> Result<()> {
if !Path::new(&self.path).exists() {
bail!("Workspace path does not exist: {}", self.path);
}
if let Some(library) = &self.library {
let lib_path = Path::new(&library.path);
if lib_path.to_string_lossy().contains("~") {
warn!(
"Library path contains '~' which may not expand correctly: {}",
library.path
);
}
}
for (idx, remote) in self.remotes.iter().enumerate() {
if let Err(e) = remote.list_repo_paths() {
warn!("Remote #{} validation failed: {}", idx + 1, e);
}
}
Ok(())
}
pub fn search(&self, pattern: &RepoPattern) -> Result<Vec<PathBuf>> {
let repos = find_git_repositories(&format!("{}/{}", self.path, pattern.full_path()))?;
Ok(repos)
}
pub fn open(&self, library: Option<&Library>, pattern: &RepoPattern) -> Result<PathBuf> {
debug!(pattern = ?pattern, "Opening repos");
let local_repos = self.search(pattern)?;
if !local_repos.is_empty() {
let repo = &local_repos[0];
info!("✓ Repository already in workspace: {}", repo.display());
if check_uncommitted_changes(repo)? {
info!(" ⚠ Has uncommitted changes");
}
if check_unpushed_commits(repo)? {
info!(" ⚠ Has unpushed commits");
}
return Ok(local_repos[0].clone());
}
if let Some(library) = library {
let relative_path = pattern.full_path();
let repo_path = format!("{}/{}", self.path, relative_path);
if library.exists(relative_path.clone()) {
info!("📦 Restoring from library: {}", relative_path);
library.restore_to_workspace(&self.path, &relative_path)?;
info!(" 🔄 Fetching latest changes...");
if let Err(e) = self.fetch_updates(&PathBuf::from(&repo_path)) {
debug!("Failed to fetch updates: {}", e);
info!(" ⚠ Could not fetch updates (continuing anyway)");
}
info!("✓ Restored {}", relative_path);
return Ok(PathBuf::from(repo_path));
}
}
info!("🔄 Cloning {}...", pattern.full_path());
let repo_path = self.clone_from_remote(pattern)?;
info!("✓ Successfully cloned to: {}", repo_path.display());
Ok(repo_path)
}
fn fetch_updates(&self, _repo_path: &Path) -> Result<()> {
Ok(())
}
pub fn drop(
&self,
library: Option<&Library>,
pattern: &RepoPattern,
delete: bool,
force: bool,
) -> Result<()> {
use tracing::{debug, info, warn};
debug!("Drop requested for pattern: {:?}", pattern);
let repos = self.search(pattern)?;
if repos.is_empty() {
warn!(
"No repositories found matching pattern: {}",
pattern.full_path()
);
return Ok(());
}
for repo in repos {
if !force {
if check_uncommitted_changes(&repo)? {
warn!(
"⚠ Refusing to drop repository with uncommitted changes: {}",
repo.display()
);
warn!(" Use --force to drop anyway");
continue;
}
if check_unpushed_commits(&repo)? {
warn!(
"⚠ Refusing to drop repository with unpushed commits: {}",
repo.display()
);
warn!(" Use --force to drop anyway");
continue;
}
}
if !delete {
if let Some(library) = library {
info!("📦 Storing {} in library", repo.display());
let relative_path = repo
.strip_prefix(&self.path)
.unwrap_or(&repo)
.to_string_lossy()
.trim_start_matches('/')
.to_string();
library.store_from_workspace(&self.path, &relative_path)?;
}
} else {
info!("🗑️ Permanently deleting {}", repo.display());
}
debug!("Removing directory: {:?}", &repo);
std::fs::remove_dir_all(&repo)?;
info!("✓ Dropped {}", repo.display());
}
Ok(())
}
pub fn drop_all(&self, library: Option<&Library>, delete: bool, force: bool) -> Result<()> {
use tracing::{debug, info, warn};
debug!("Drop all requested in current directory");
let cwd = std::env::current_dir()?;
let repos = find_git_repositories(&cwd.to_string_lossy())?;
if repos.is_empty() {
info!("No repositories found in current directory");
return Ok(());
}
info!("Found {} repository(ies) in current directory", repos.len());
let mut dropped = 0;
let mut skipped = 0;
for repo in repos {
if !force {
if check_uncommitted_changes(&repo)? {
warn!(
"⚠ Skipping repository with uncommitted changes: {}",
repo.display()
);
skipped += 1;
continue;
}
if check_unpushed_commits(&repo)? {
warn!(
"⚠ Skipping repository with unpushed commits: {}",
repo.display()
);
skipped += 1;
continue;
}
}
if !delete {
if let Some(library) = library {
info!("📦 Storing {} in library", repo.display());
let relative_path = repo
.strip_prefix(&self.path)
.unwrap_or(&repo)
.to_string_lossy()
.trim_start_matches('/')
.to_string();
library.store_from_workspace(&self.path, &relative_path)?;
}
} else {
info!("🗑️ Permanently deleting {}", repo.display());
}
debug!("Removing directory: {:?}", &repo);
std::fs::remove_dir_all(&repo)?;
info!("✓ Dropped {}", repo.display());
dropped += 1;
}
if dropped > 0 {
info!("✓ Dropped {} repository(ies)", dropped);
}
if skipped > 0 {
warn!(
"⚠ Skipped {} repository(ies) - use --force to drop anyway",
skipped
);
}
Ok(())
}
fn clone_from_remote(&self, pattern: &RepoPattern) -> Result<PathBuf> {
use tracing::info;
let dest_path = format!("{}/{}", self.path, pattern.full_path());
if let Some((provider, repo_path)) = pattern.provider_and_path() {
let clone_url = format!("https://{}/{}", provider, repo_path);
std::fs::create_dir_all(std::path::Path::new(&dest_path).parent().unwrap())?;
info!("Cloning {} to {}", clone_url, dest_path);
run_fun!(git clone $clone_url $dest_path)?;
return Ok(PathBuf::from(dest_path));
}
bail!("No provider specified. Use full path like github.com/user/repo")
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Library {
pub path: String,
}
impl Library {
pub fn store_from_workspace(&self, workspace_path: &str, relative_path: &str) -> Result<()> {
use tracing::debug;
std::fs::create_dir_all(&self.path).map_err(|e| {
anyhow::anyhow!("Failed to create library directory {}: {}", self.path, e)
})?;
let source = format!("{}/{}/.git", workspace_path, relative_path);
let dest = format!("{}/{}", self.path, relative_path);
if std::fs::metadata(&source).is_err() {
bail!("Repository .git directory not found: {}", source);
}
run_fun!(git -C $source config core.bare true)
.map_err(|e| anyhow::anyhow!("Failed to configure repository as bare: {}", e))?;
debug!(source = %source, dest = %dest, "Storing repository in library");
if let Some(parent) = Path::new(&dest).parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("Failed to create library parent directory: {}", e))?;
}
if std::fs::metadata(&dest).is_ok() {
debug!("Removing existing library entry: {}", dest);
std::fs::remove_dir_all(&dest)
.map_err(|e| anyhow::anyhow!("Failed to remove existing library entry: {}", e))?;
}
run_fun!(mv $source $dest)
.map_err(|e| anyhow::anyhow!("Failed to move repository to library: {}", e))?;
Ok(())
}
pub fn restore_to_workspace(&self, workspace_path: &str, relative_path: &str) -> Result<()> {
let source = format!("{}/{}", self.path, relative_path);
let dest = format!("{}/{}", workspace_path, relative_path);
if std::fs::metadata(&source).is_err() {
bail!(
"Repository not found in library for path: {}",
relative_path
);
}
if let Some(parent) = Path::new(&dest).parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("Failed to create parent directory: {}", e))?;
}
run_fun!(git clone $source $dest)
.map_err(|e| anyhow::anyhow!("Failed to restore repository from library: {}", e))?;
Ok(())
}
pub fn exists(&self, repo_path: String) -> bool {
std::fs::metadata(format!("{}/{}", self.path, repo_path)).is_ok()
}
pub fn list(&self) -> Result<Vec<String>> {
use tracing::debug;
if !Path::new(&self.path).exists() {
return Ok(Vec::new());
}
let mut repos = Vec::new();
fn find_repos(base_path: &str, current_path: &Path, repos: &mut Vec<String>) -> Result<()> {
if current_path.is_dir() {
if gix::open(current_path).is_ok() {
if let Ok(rel_path) = current_path.strip_prefix(base_path) {
let repo_path = rel_path.to_string_lossy().to_string();
if !repo_path.is_empty() {
repos.push(repo_path);
}
}
return Ok(()); }
if let Ok(entries) = std::fs::read_dir(current_path) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
find_repos(base_path, &path, repos)?;
}
}
}
Ok(())
}
find_repos(&self.path, Path::new(&self.path), &mut repos)?;
debug!("Found {} repositories in library", repos.len());
repos.sort();
Ok(repos)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_workspace_default() {
let workspace = Workspace::default();
assert!(workspace.library.is_some());
assert!(!workspace.path.is_empty());
}
#[test]
fn test_library_exists() {
let temp_dir = TempDir::new().unwrap();
let library = Library {
path: temp_dir.path().to_string_lossy().to_string(),
};
let repo_path = "/test/repo";
assert!(!library.exists(repo_path.to_string()));
let library_path = format!("{}{}", library.path, repo_path);
fs::create_dir_all(&library_path).unwrap();
assert!(library.exists(repo_path.to_string()));
}
#[test]
fn test_find_git_repositories() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let repo1 = base_path.join("repo1");
fs::create_dir_all(repo1.join(".git")).unwrap();
let repo2 = base_path.join("nested/repo2");
fs::create_dir_all(repo2.join(".git")).unwrap();
let not_repo = base_path.join("not_a_repo");
fs::create_dir_all(¬_repo).unwrap();
let repos = find_git_repositories(&base_path.to_string_lossy()).unwrap();
assert_eq!(repos.len(), 2);
assert!(repos.iter().any(|p| p.ends_with("repo1")));
assert!(repos.iter().any(|p| p.ends_with("repo2")));
}
#[test]
fn test_parse_with_provider() -> Result<(), Box<dyn Error>> {
let pattern = str::parse::<RepoPattern>("github.com/user/repo")?;
assert_eq!(pattern.provider, Some("github.com".to_string()));
assert_eq!(pattern.path, "user/repo".to_string());
Ok(())
}
#[test]
fn test_parse_without_provider() -> Result<(), Box<dyn Error>> {
let pattern = str::parse::<RepoPattern>("user/repo")?;
assert_eq!(pattern.provider, None);
assert_eq!(pattern.path, "user/repo".to_string());
Ok(())
}
#[test]
fn test_parse_simple_path() -> Result<(), Box<dyn Error>> {
let pattern = str::parse::<RepoPattern>("repo")?;
assert_eq!(pattern.provider, None);
assert_eq!(pattern.path, "repo".to_string());
Ok(())
}
#[test]
fn test_parse_gitlab_path() -> Result<(), Box<dyn Error>> {
let pattern = str::parse::<RepoPattern>("gitlab.com/company/project/repo")?;
assert_eq!(pattern.provider, Some("gitlab.com".to_string()));
assert_eq!(pattern.path, "company/project/repo".to_string());
Ok(())
}
#[test]
fn test_provider_and_path() {
let pattern = RepoPattern {
provider: Some("github.com".to_string()),
path: "user/repo".to_string(),
};
let (provider, path) = pattern.provider_and_path().unwrap();
assert_eq!(provider, "github.com");
assert_eq!(path, "user/repo");
}
#[test]
fn test_provider_and_path_none() {
let pattern = RepoPattern {
provider: None,
path: "user/repo".to_string(),
};
assert!(pattern.provider_and_path().is_none());
}
#[test]
fn test_full_path_with_provider() {
let pattern = RepoPattern {
provider: Some("github.com".to_string()),
path: "user/repo".to_string(),
};
assert_eq!(pattern.full_path(), "github.com/user/repo");
}
#[test]
fn test_full_path_without_provider() {
let pattern = RepoPattern {
provider: None,
path: "user/repo".to_string(),
};
assert_eq!(pattern.full_path(), "user/repo");
}
}