Initial upload: DotMigrate dotfiles migration tool with CI/CD
Some checks failed
CI / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-02-04 09:53:04 +00:00
parent a8123c83de
commit 3f714a309c

362
src/sync/mod.rs Normal file
View File

@@ -0,0 +1,362 @@
use crate::config::Config;
use crate::detect::DetectedDotfile;
use crate::cli::SyncBackend;
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::fs;
#[cfg(feature = "git2")]
pub struct GitBackend {
config: Config,
repo: Option<git2::Repository>,
}
#[cfg(not(feature = "git2"))]
pub struct GitBackend {
config: Config,
}
#[cfg(feature = "git2")]
impl GitBackend {
pub fn new(config: &Config) -> Result<Self> {
let repo = match config.sync.remote {
Some(ref remote) if remote.starts_with("http") || remote.starts_with("git@") => {
let clone_path = config.sync.path.parent()
.map(|p| p.join("dotfiles"))
.unwrap_or_else(|| std::path::PathBuf::from("dotfiles"));
Some(git2::Repository::clone(remote, clone_path)?)
}
_ => None,
};
Ok(Self {
config: config.clone(),
repo,
})
}
pub fn push(&self, dotfiles: &[DetectedDotfile]) -> Result<()> {
if let Some(ref repo) = self.repo {
let mut index = repo.index()?;
for dotfile in dotfiles {
if dotfile.path.exists() {
match repo.status_file(&dotfile.path) {
Ok(_) => {
index.add_path(&dotfile.path)?;
}
Err(_) => {
index.add_path(&dotfile.path)?;
}
}
}
}
let oid = index.write_tree()?;
let tree = repo.find_tree(oid)?;
let sig = git2::Signature::now("DotMigrate", "dotmigrate@example.com")?;
let commit_message = format!("Update {} dotfiles", dotfiles.len());
if let Ok(head) = repo.head() {
if let Ok(target) = head.target() {
if let Ok(commit) = repo.find_commit(target) {
repo.commit(Some("HEAD"), &sig, &sig, &commit_message, &tree, std::slice::from_ref(&commit))?;
} else {
repo.commit(Some("HEAD"), &sig, &sig, &commit_message, &tree, &[])?;
}
} else {
repo.commit(Some("HEAD"), &sig, &sig, &commit_message, &tree, &[])?;
}
} else {
repo.commit(Some("HEAD"), &sig, &sig, &commit_message, &tree, &[])?;
}
if let Ok(mut remote) = repo.find_remote("origin") {
remote.push(&["refs/heads/main:refs/heads/main"], None)?;
}
Ok(())
} else {
anyhow::bail!("Git repository not initialized");
}
}
pub fn pull(&self, dotfiles: &mut Vec<DetectedDotfile>) -> Result<()> {
if let Some(ref _repo) = self.repo {
Ok(())
} else {
anyhow::bail!("Git repository not initialized");
}
}
pub fn list_remote(&self) -> Result<Vec<PathBuf>> {
Ok(Vec::new())
}
pub fn get_name(&self) -> SyncBackend {
SyncBackend::Git
}
}
#[cfg(not(feature = "git2"))]
impl GitBackend {
pub fn new(config: &Config) -> Result<Self> {
Ok(Self {
config: config.clone(),
})
}
pub fn push(&self, _dotfiles: &[DetectedDotfile]) -> Result<()> {
anyhow::bail!("Git backend requires git2 feature. Rebuild with --features git2");
}
pub fn pull(&self, _dotfiles: &mut Vec<DetectedDotfile>) -> Result<()> {
anyhow::bail!("Git backend requires git2 feature. Rebuild with --features git2");
}
pub fn list_remote(&self) -> Result<Vec<PathBuf>> {
anyhow::bail!("Git backend requires git2 feature. Rebuild with --features git2");
}
pub fn get_name(&self) -> SyncBackend {
SyncBackend::Git
}
}
#[cfg(feature = "rusoto_s3")]
pub struct S3Backend {
config: Config,
bucket: String,
client: Option<rusoto_s3::S3Client>,
}
#[cfg(not(feature = "rusoto_s3"))]
pub struct S3Backend {
config: Config,
bucket: String,
}
#[cfg(feature = "rusoto_s3")]
impl S3Backend {
pub fn new(config: &Config) -> Result<Self> {
let bucket = config.sync.remote.clone()
.unwrap_or_else(|| "dotmigrate-bucket".to_string());
let client = None;
Ok(Self {
config: config.clone(),
bucket,
client,
})
}
pub fn push(&self, _dotfiles: &[DetectedDotfile]) -> Result<()> {
anyhow::bail!("S3 backend implementation pending");
}
pub fn pull(&self, _dotfiles: &mut Vec<DetectedDotfile>) -> Result<()> {
anyhow::bail!("S3 backend implementation pending");
}
pub fn list_remote(&self) -> Result<Vec<PathBuf>> {
Ok(Vec::new())
}
pub fn get_name(&self) -> SyncBackend {
SyncBackend::S3
}
}
#[cfg(not(feature = "rusoto_s3"))]
impl S3Backend {
pub fn new(config: &Config) -> Result<Self> {
let bucket = config.sync.remote.clone()
.unwrap_or_else(|| "dotmigrate-bucket".to_string());
Ok(Self {
config: config.clone(),
bucket,
})
}
pub fn push(&self, _dotfiles: &[DetectedDotfile]) -> Result<()> {
anyhow::bail!("S3 backend requires rusoto_s3 feature. Rebuild with --features rusoto_s3");
}
pub fn pull(&self, _dotfiles: &mut Vec<DetectedDotfile>) -> Result<()> {
anyhow::bail!("S3 backend requires rusoto_s3 feature. Rebuild with --features rusoto_s3");
}
pub fn list_remote(&self) -> Result<Vec<PathBuf>> {
anyhow::bail!("S3 backend requires rusoto_s3 feature. Rebuild with --features rusoto_s3");
}
pub fn get_name(&self) -> SyncBackend {
SyncBackend::S3
}
}
pub struct DirectBackend {
config: Config,
remote_path: Option<PathBuf>,
}
impl DirectBackend {
pub fn new(config: &Config) -> Result<Self> {
let remote_path = config.sync.remote.clone()
.map(PathBuf::from);
Ok(Self {
config: config.clone(),
remote_path,
})
}
pub fn push(&self, dotfiles: &[DetectedDotfile]) -> Result<()> {
let target_dir = self.remote_path
.as_ref()
.or_else(|| self.config.sync.path.parent())
.context("No remote path specified")?;
if !target_dir.exists() {
std::fs::create_dir_all(target_dir)
.context("Failed to create target directory")?;
}
for dotfile in dotfiles {
let file_name = dotfile.path.file_name()
.context("Dotfile has no file name")?;
let target_path = target_dir.join(file_name);
std::fs::copy(&dotfile.path, &target_path)
.context(format!("Failed to copy {:?} to {:?}", dotfile.path, target_path))?;
}
Ok(())
}
pub fn pull(&self, dotfiles: &mut Vec<DetectedDotfile>) -> Result<()> {
let source_dir = self.remote_path
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Remote path not specified"))?;
if !source_dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(source_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let detected = DetectedDotfile::new(path);
dotfiles.push(detected);
}
}
Ok(())
}
pub fn list_remote(&self) -> Result<Vec<PathBuf>> {
let source_dir = self.remote_path
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Remote path not specified"))?;
let mut files = Vec::new();
if source_dir.exists() {
for entry in std::fs::read_dir(source_dir)? {
let entry = entry?;
if entry.path().is_file() {
files.push(entry.path());
}
}
}
Ok(files)
}
pub fn get_name(&self) -> SyncBackend {
SyncBackend::Direct
}
}
pub struct SyncManager {
config: Config,
backend_type: SyncBackend,
}
impl SyncManager {
pub fn new(config: &Config, backend_type: SyncBackend) -> Self {
Self {
config: config.clone(),
backend_type,
}
}
pub fn sync(
&self,
local_dotfiles: &[DetectedDotfile],
_strategy: Option<crate::cli::MergeStrategy>,
) -> Result<()> {
match self.backend_type {
SyncBackend::Git => {
let backend = GitBackend::new(&self.config)?;
backend.push(local_dotfiles).context("Push failed")?;
let mut dotfiles = local_dotfiles.to_vec();
backend.pull(&mut dotfiles).context("Pull failed")?;
}
SyncBackend::S3 => {
let backend = S3Backend::new(&self.config)?;
backend.push(local_dotfiles).context("Push failed")?;
let mut dotfiles = local_dotfiles.to_vec();
backend.pull(&mut dotfiles).context("Pull failed")?;
}
SyncBackend::Direct => {
let backend = DirectBackend::new(&self.config)?;
backend.push(local_dotfiles).context("Push failed")?;
let mut dotfiles = local_dotfiles.to_vec();
backend.pull(&mut dotfiles).context("Pull failed")?;
}
}
Ok(())
}
pub fn push(&self, dotfiles: &[DetectedDotfile]) -> Result<()> {
match self.backend_type {
SyncBackend::Git => {
let backend = GitBackend::new(&self.config)?;
backend.push(dotfiles)
}
SyncBackend::S3 => {
let backend = S3Backend::new(&self.config)?;
backend.push(dotfiles)
}
SyncBackend::Direct => {
let backend = DirectBackend::new(&self.config)?;
backend.push(dotfiles)
}
}
}
pub fn pull(&self, dotfiles: &mut Vec<DetectedDotfile>) -> Result<()> {
match self.backend_type {
SyncBackend::Git => {
let backend = GitBackend::new(&self.config)?;
backend.pull(dotfiles)
}
SyncBackend::S3 => {
let backend = S3Backend::new(&self.config)?;
backend.pull(dotfiles)
}
SyncBackend::Direct => {
let backend = DirectBackend::new(&self.config)?;
backend.pull(dotfiles)
}
}
}
}