use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use git2::build::RepoBuilder;
use walkdir::WalkDir;
use crate::manifest::Manifest;
pub use crate::project::Project;
use crate::project::ProjectOpts;
use crate::{archetype, git_util, manifest};
const DOT_CODEBASE_DIR: &str = ".codebase";
const DEFAULT_REMOTE: &str = "origin";
const README_MD: &str = r#"
# dot-codebase
This repository contains a custom .codebase configuration.
See [this repository](https://github.com/codebase-rs/codebase) for more details.
## How to use it
One can restore this codebase by issuing the following command:
```sh
codebase clone {{REMOTE}} Codebase
```
"#;
pub struct Codebase {
root_path: PathBuf,
local_path: PathBuf,
config_path: PathBuf,
repo: git2::Repository,
}
impl Codebase {
fn new<A: AsRef<Path>, B: AsRef<Path>, C: AsRef<Path>>(
root_path: A,
local_path: B,
config_path: C,
repo: git2::Repository,
) -> Self {
Codebase {
root_path: root_path.as_ref().to_path_buf(),
local_path: local_path.as_ref().to_path_buf(),
config_path: config_path.as_ref().to_path_buf(),
repo,
}
}
pub fn init<A: AsRef<Path>, B: AsRef<Path>>(
directory: A,
remote: &Option<&str>,
import: bool,
config_dir: B,
) -> anyhow::Result<Codebase> {
if Codebase::exists(&directory) {
return Err(anyhow::anyhow!(format!(
"A codebase already exists at {}",
directory.as_ref().display()
)));
}
fs::create_dir_all(&config_dir)?;
let mut manifest = Manifest::new();
if import && directory.as_ref().exists() {
for entry in WalkDir::new(&directory)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_str().unwrap() == ".git")
{
let path: &Path = entry.path().parent().unwrap();
let repo = git2::Repository::open(path)?;
let local_path = path.strip_prefix(&directory)?;
manifest.add_project(local_path, Project::from(repo))?;
}
}
fs::create_dir_all(&directory)?;
let conf_dir = directory.as_ref().join(DOT_CODEBASE_DIR);
let repo = git2::Repository::init(&conf_dir)?;
if let Some(value) = remote {
repo.remote(DEFAULT_REMOTE, value)?;
}
let codebase = Codebase::new(directory, PathBuf::new(), config_dir, repo);
codebase.write_manifest(&manifest)?;
let mut file = fs::File::create(conf_dir.join("README.md"))?;
file.write_all(
README_MD
.replace("{{REMOTE}}", remote.unwrap_or("YOUR_REMOTE"))
.as_bytes(),
)?;
git_util::commit_all_changes(&codebase.repo, "Initial commit")?;
Ok(codebase)
}
pub fn clone<'a, A: AsRef<Path>, B: AsRef<Path>>(
directory: A,
remote: &str,
callback: impl Fn(&str, &Project) + 'a,
config_dir: B,
) -> anyhow::Result<Codebase> {
if Codebase::exists(&directory) {
return Err(anyhow::anyhow!(format!(
"A codebase already exists at {}",
directory.as_ref().display()
)));
}
fs::create_dir_all(&config_dir)?;
fs::create_dir_all(&directory)?;
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git_util::get_ssh_key(&username_from_url)
});
let mut opts = git2::FetchOptions::new();
opts.remote_callbacks(callbacks);
let mut repo_builder = RepoBuilder::new();
repo_builder.fetch_options(opts);
let dot_codebase = directory.as_ref().join(DOT_CODEBASE_DIR);
let repo = repo_builder.clone(&remote, &dot_codebase)?;
let codebase = Codebase::new(&directory, &PathBuf::new(), config_dir, repo);
let manifest = codebase.read_manifest()?;
for (path, project) in manifest.projects.iter() {
codebase.install_project(
&path,
&project.remote,
&ProjectOpts::from(project.clone()),
)?;
callback(&path, &project);
}
Ok(codebase)
}
pub fn open<A: AsRef<Path>, B: AsRef<Path>>(
directory: A,
config_dir: B,
) -> anyhow::Result<Codebase> {
let root_dir = directory
.as_ref()
.ancestors()
.find(|path| Codebase::exists(&path))
.map(|path| path.to_path_buf());
if root_dir.is_none() {
return Err(anyhow::anyhow!(format!(
"No codebase found at {}",
directory.as_ref().display()
)));
}
fs::create_dir_all(&config_dir)?;
let root_dir = root_dir.unwrap();
let config_repo = git2::Repository::open(root_dir.join(DOT_CODEBASE_DIR))?;
let local_path = directory.as_ref().strip_prefix(&root_dir)?.to_path_buf();
Ok(Codebase::new(root_dir, local_path, config_dir, config_repo))
}
pub fn exists<P: AsRef<Path>>(directory: P) -> bool {
let config_dir = directory.as_ref().join(DOT_CODEBASE_DIR);
config_dir.exists()
}
pub fn add_project<P: AsRef<Path>>(
&self,
directory: &Option<P>,
remote: &str,
options: &ProjectOpts,
) -> anyhow::Result<Project> {
let project_name = get_repo_name(remote)?;
let mut project_path = self.get_local_path();
if directory.is_some() {
project_path = project_path.join(directory.as_ref().unwrap());
}
project_path = project_path.join(project_name);
let project = self.install_project(&project_path, &remote, &options)?;
let mut manifest = self.read_manifest()?;
manifest.add_project(&project_path, project.clone())?;
self.write_manifest(&manifest)?;
git_util::commit_all_changes(
&self.repo,
&format!(
"Add project {} in {}",
&project.remote,
project_path.display()
),
)?;
Ok(project)
}
pub fn remove_project<P: AsRef<Path>>(
&self,
directory: P,
remove_from_disk: bool,
) -> anyhow::Result<()> {
let project = self.find_project(&directory)?;
if project.is_none() {
return Err(anyhow::anyhow!(format!(
"No project with path `{}` found",
directory.as_ref().display()
)));
}
let mut manifest = self.read_manifest()?;
manifest.remove_project(&directory)?;
self.write_manifest(&manifest)?;
if remove_from_disk {
let path = self.root_path.clone().join(&directory);
fs::remove_dir_all(&path)?;
}
git_util::commit_all_changes(
&self.repo,
&format!("Remove project {}", project.unwrap().remote),
)?;
Ok(())
}
pub fn read_manifest(&self) -> anyhow::Result<Manifest> {
let manifest_path = self
.root_path
.clone()
.join(DOT_CODEBASE_DIR)
.join(manifest::MANIFEST_FILE);
Manifest::read_file(&manifest_path)
}
pub fn push(&self) -> anyhow::Result<()> {
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git_util::get_ssh_key(&username_from_url)
});
let mut opts = git2::PushOptions::new();
opts.remote_callbacks(callbacks);
let mut remote = self.repo.find_remote(DEFAULT_REMOTE)?;
remote.push(&["refs/heads/master"], Some(&mut opts))?;
Ok(())
}
pub fn pull(&self, remove_from_disk: bool) -> anyhow::Result<(Vec<Project>, Vec<Project>)> {
let old_manifest = self.read_manifest()?;
git_util::pull(&self.repo, DEFAULT_REMOTE, "master")?;
let new_manifest = self.read_manifest()?;
let mut add_projects: Vec<Project> = Vec::new();
let mut rm_projects: Vec<Project> = Vec::new();
for (path, project) in &new_manifest.projects {
if !old_manifest.projects.contains_key(path) {
add_projects.push(project.clone());
self.install_project(&path, &project.remote, &ProjectOpts::from(project.clone()))?;
}
let mut project = project.clone();
let repo = git2::Repository::open(self.root_path.clone().join(path))?;
project.configure(
&repo,
&ProjectOpts::from(project.clone()),
self.config_path.clone(),
)?;
}
for (path, project) in &old_manifest.projects {
if !new_manifest.projects.contains_key(path) {
rm_projects.push(project.clone());
if remove_from_disk {
let path = self.root_path.clone().join(&path);
fs::remove_dir_all(path)?;
}
}
}
Ok((add_projects, rm_projects))
}
pub fn update_projects<'a>(&self, callback: impl Fn(&Project) + 'a) -> anyhow::Result<()> {
let manifest = self.read_manifest()?;
for (path, project) in manifest.projects {
let path = self.root_path.clone().join(&path);
let repo = git2::Repository::open(path)?;
git_util::pull(&repo, DEFAULT_REMOTE, &project.branch)?;
callback(&project);
}
Ok(())
}
pub fn config_project<P: AsRef<Path>>(
&self,
directory: P,
options: &ProjectOpts,
) -> anyhow::Result<Project> {
let mut manifest = self.read_manifest()?;
let directory = self.get_local_path().join(&directory);
let project = manifest.find_project(&directory);
if project.is_none() {
return Err(anyhow::anyhow!(format!(
"No project with path `{}` found",
directory.display()
)));
}
let mut project = project.unwrap();
let repo = git2::Repository::open(self.root_path.clone().join(&directory))?;
project.configure(&repo, options, self.config_path.clone())?;
manifest.update_project(&directory, project.clone())?;
self.write_manifest(&manifest)?;
git_util::commit_all_changes(&self.repo, &format!("Configure project {}", project.remote))?;
Ok(project)
}
pub fn move_project<P: AsRef<Path>>(
&self,
src_directory: P,
dst_directory: P,
) -> anyhow::Result<()> {
let src_directory = self.get_local_path().join(&src_directory);
let dst_directory = self.get_local_path().join(&dst_directory);
let project = self.find_project(&src_directory)?;
if project.is_none() {
return Err(anyhow::anyhow!(format!(
"No project with path `{}` found",
src_directory.display()
)));
}
fs::create_dir_all(&self.root_path.join(&dst_directory))?;
fs::rename(
self.root_path.join(&src_directory),
self.root_path.join(&dst_directory),
)?;
let mut manifest = self.read_manifest()?;
manifest.move_project(&src_directory, &dst_directory)?;
self.write_manifest(&manifest)?;
let project = project.unwrap();
git_util::commit_all_changes(
&self.repo,
&format!(
"Move project {} from {} to {}",
project.remote,
src_directory.display(),
dst_directory.display()
),
)?;
Ok(())
}
pub fn create_project<P: AsRef<Path>>(
&self,
directory: P,
remote: &str,
options: &ProjectOpts,
archetype_name: &Option<&str>,
archetype_config: &BTreeMap<String, String>,
) -> anyhow::Result<(String, Project)> {
let directory = self.get_local_path().join(directory);
if self.find_project(&directory)?.is_some() {
return Err(anyhow::anyhow!(format!(
"A project already exist at {}",
directory.display()
)));
}
let project_path = self.root_path.clone().join(&directory);
if let Some(parents_dir) = project_path.parent() {
fs::create_dir_all(parents_dir)?;
}
if let Some(archetype_name) = archetype_name {
let project_name = directory.file_name().unwrap().to_str().unwrap();
let mut archetype_config = archetype_config.clone();
archetype_config.insert("NAME".to_string(), project_name.to_string());
let parent_directory = project_path
.parent()
.unwrap_or_else(|| self.root_path.as_path());
archetype::execute(
&archetype_name,
&archetype_config,
&parent_directory,
self.config_path.clone(),
)?;
}
let branch = options
.branch
.clone()
.unwrap_or_else(|| "master".to_string());
let mut opts = git2::RepositoryInitOptions::new();
opts.origin_url(remote);
opts.initial_head(&format!("refs/heads/{}", branch));
let repo = git2::Repository::init_opts(&project_path, &opts)?;
git_util::commit_all_changes(&repo, "Initial commit")?;
let options = ProjectOpts {
branch: Some(branch),
configuration: options.configuration.clone(),
hook: options.hook.clone(),
};
let mut project = Project::empty(remote);
project.configure(&repo, &options, self.config_path.clone())?;
let mut manifest = self.read_manifest()?;
manifest.add_project(&directory, project.clone())?;
self.write_manifest(&manifest)?;
git_util::commit_all_changes(
&self.repo,
&format!(
"Create project {} in {}",
project.remote,
directory.display()
),
)?;
Ok((directory.to_str().unwrap().to_string(), project))
}
pub fn local_path(&self) -> PathBuf {
self.local_path.clone()
}
fn write_manifest(&self, manifest: &Manifest) -> anyhow::Result<()> {
let manifest_path = self
.root_path
.clone()
.join(DOT_CODEBASE_DIR)
.join(manifest::MANIFEST_FILE);
manifest.write_file(&manifest_path)
}
fn install_project<P: AsRef<Path>>(
&self,
directory: P,
remote: &str,
options: &ProjectOpts,
) -> anyhow::Result<Project> {
let project_path = self.root_path.clone().join(&directory);
fs::create_dir_all(&project_path)?;
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git_util::get_ssh_key(&username_from_url)
});
let mut opts = git2::FetchOptions::new();
opts.remote_callbacks(callbacks);
let mut repo_builder = RepoBuilder::new();
repo_builder.fetch_options(opts);
if let Some(branch) = &options.branch {
repo_builder.branch(branch);
}
let repo = repo_builder.clone(remote, &project_path)?;
let branch = options.branch.clone().or_else(|| {
if let Some(branch) = git_util::get_active_branch(&repo).unwrap() {
return Some(branch);
}
repo.set_head("refs/heads/master").unwrap();
Some("master".to_string())
});
if branch.is_none() {
return Err(anyhow::anyhow!("Unable to retrieve active branch name"));
}
let options = ProjectOpts {
branch,
configuration: options.configuration.clone(),
hook: options.hook.clone(),
};
let mut project = Project::empty(remote);
project.configure(&repo, &options, self.config_path.clone())?;
Ok(project)
}
fn find_project<P: AsRef<Path>>(&self, directory: P) -> anyhow::Result<Option<Project>> {
let manifest = self.read_manifest()?;
Ok(manifest.find_project(directory))
}
fn get_local_path(&self) -> PathBuf {
self.local_path.clone() }
}
fn get_repo_name(remote: &str) -> anyhow::Result<String> {
let parts: Vec<&str> = remote.split('/').collect();
let repo_name = parts
.last()
.ok_or_else(|| anyhow::anyhow!("Malformed repo URI"))?;
Ok(repo_name.replace(".git", ""))
}
#[cfg(test)]
mod tests {
use crate::codebase::get_repo_name;
#[test]
fn test_get_repo_name() {
assert_eq!(
get_repo_name("https://github.com/codebase-rs/codebase.git").unwrap(),
"codebase"
);
assert_eq!(
get_repo_name("git@github.com:codebase-rs/codebase.git").unwrap(),
"codebase"
);
}
}