use crate::cargo::{CargoTransform, TransformContext};
use crate::config::{SplitMode, WorkspaceMode};
use crate::error::RailResult;
use crate::git::SystemGit;
use crate::git::mappings::MappingStore;
use crate::progress;
use crate::sync::conflict::{ConflictInfo, ConflictResolver, ConflictStrategy};
use crate::utils;
use crate::workspace::WorkspaceContext;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct SyncConfig {
pub crate_name: String,
pub crate_paths: Vec<PathBuf>,
pub mode: SplitMode,
pub workspace_mode: WorkspaceMode,
pub target_repo_path: PathBuf,
pub branch: String,
pub remote_url: String,
}
#[derive(Default)]
pub struct SyncResult {
pub commits_synced: usize,
pub conflicts: Vec<ConflictInfo>,
}
#[derive(Debug, Clone)]
pub enum SyncDirection {
MonoToRemote,
RemoteToMono,
Both,
None,
}
pub struct ConflictResolutionResult {
pub conflicts: Vec<ConflictInfo>,
pub changed_files: Vec<(PathBuf, char)>,
}
pub struct SyncEngine<'a> {
ctx: &'a WorkspaceContext,
config: SyncConfig,
mapping_store: MappingStore,
transform: CargoTransform,
conflict_resolver: ConflictResolver,
loaded_repos: std::collections::HashSet<PathBuf>,
}
impl<'a> SyncEngine<'a> {
pub fn new(ctx: &'a WorkspaceContext, config: SyncConfig, conflict_strategy: ConflictStrategy) -> RailResult<Self> {
let mapping_store = MappingStore::new(config.crate_name.clone());
let transformer = CargoTransform::new(ctx.cargo.metadata().clone());
let temp_dir = std::env::temp_dir().join(format!(
"cargo-rail-conflicts-{}-{}-{}",
config.crate_name,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|_| std::time::Duration::from_secs(0))
.as_nanos()
));
std::fs::create_dir_all(&temp_dir)?;
let conflict_resolver = ConflictResolver::new(conflict_strategy, temp_dir);
Ok(Self {
ctx,
config,
mapping_store,
transform: transformer,
conflict_resolver,
loaded_repos: std::collections::HashSet::new(),
})
}
fn ensure_mappings_loaded(&mut self, repo_path: &Path) -> RailResult<()> {
if !self.loaded_repos.contains(repo_path) {
self.mapping_store.load(repo_path)?;
self.loaded_repos.insert(repo_path.to_path_buf());
}
Ok(())
}
fn get_branch_ref(&self) -> String {
if utils::is_local_path(&self.config.remote_url) {
self.config.branch.clone()
} else {
format!("origin/{}", self.config.branch)
}
}
fn mono_path_in_scope(&self, path: &Path) -> bool {
match self.config.mode {
SplitMode::Single => self
.config
.crate_paths
.first()
.is_some_and(|crate_path| path.starts_with(crate_path)),
SplitMode::Combined => self
.config
.crate_paths
.iter()
.any(|crate_path| path.starts_with(crate_path)),
}
}
pub fn sync_to_remote(&mut self) -> RailResult<SyncResult> {
progress!(" Syncing monorepo → remote...");
self.ensure_mappings_loaded(self.ctx.workspace_root())?;
let target_repo_path = self.config.target_repo_path.clone();
let remote_git = SystemGit::open(&target_repo_path)?;
if !utils::is_local_path(&self.config.remote_url) {
remote_git.fetch_from_remote("origin")?;
self.mapping_store.fetch_notes(&target_repo_path, "origin")?;
} else {
progress!(" Skipping fetch (local testing mode)");
}
self.loaded_repos.remove(&target_repo_path);
self.ensure_mappings_loaded(&target_repo_path)?;
let last_synced_mono = self.find_last_synced_mono_commit()?;
let new_commits = self.ctx.git()?.git().get_commits_touching_paths(
&self.config.crate_paths,
last_synced_mono.as_deref(),
"HEAD",
)?;
if new_commits.is_empty() {
progress!(" No new commits to sync");
} else {
progress!(" Syncing {} commits to remote...", new_commits.len());
let mut synced_count = 0;
let mut current_remote_head = remote_git.head_commit()?;
for commit in &new_commits {
if self.mapping_store.has_mapping(&commit.sha) {
continue;
}
if commit.message.contains("Rail-Origin: remote@") {
continue;
}
let remote_sha = self.apply_mono_commit_to_remote(commit, &remote_git, ¤t_remote_head)?;
self.mapping_store.record_mapping(&commit.sha, &remote_sha)?;
synced_count += 1;
current_remote_head = remote_sha; }
self.mapping_store.save(self.ctx.workspace_root())?;
self.mapping_store.save(&self.config.target_repo_path)?;
if synced_count > 0 && !utils::is_local_path(&self.config.remote_url) {
remote_git.push_to_remote("origin", &self.config.branch)?;
self.mapping_store.push_notes(&self.config.target_repo_path, "origin")?;
}
return Ok(SyncResult {
commits_synced: synced_count,
conflicts: Vec::new(),
});
}
let synced_count = 0;
self.mapping_store.save(self.ctx.workspace_root())?;
self.mapping_store.save(&self.config.target_repo_path)?;
if synced_count > 0 {
if !utils::is_local_path(&self.config.remote_url) {
remote_git.push_to_remote("origin", &self.config.branch)?;
self.mapping_store.push_notes(&self.config.target_repo_path, "origin")?;
} else {
progress!(" Skipping push (local testing mode)");
}
}
Ok(SyncResult {
commits_synced: synced_count,
conflicts: Vec::new(),
})
}
pub fn sync_from_remote(&mut self) -> RailResult<SyncResult> {
progress!(" Syncing remote → monorepo...");
let _current_branch = self.ctx.git()?.git().current_branch()?;
self.ensure_mappings_loaded(self.ctx.workspace_root())?;
let target_repo_path = self.config.target_repo_path.clone();
let remote_git = SystemGit::open(&target_repo_path)?;
if !utils::is_local_path(&self.config.remote_url) {
remote_git.fetch_from_remote("origin")?;
self.mapping_store.fetch_notes(&target_repo_path, "origin")?;
} else {
progress!(" Skipping fetch (local testing mode)");
}
self.loaded_repos.remove(&target_repo_path);
self.ensure_mappings_loaded(&target_repo_path)?;
let last_synced_remote = self.find_last_synced_remote_commit(&remote_git)?;
let branch_ref = self.get_branch_ref();
let new_commits = if let Some(ref last) = last_synced_remote {
remote_git.get_commits_touching_path(Path::new("."), Some(last), &branch_ref)?
} else {
remote_git.get_commits_touching_path(Path::new("."), None, &branch_ref)?
};
let commits_to_sync: Vec<_> = new_commits
.iter()
.filter(|c| {
if c.message.contains("Rail-Origin: mono@") {
return false;
}
if self.mapping_store.has_reverse_mapping(&c.sha) {
return false;
}
true
})
.collect();
if commits_to_sync.is_empty() {
progress!(" No new commits to sync (already up-to-date)");
return Ok(SyncResult {
commits_synced: 0,
conflicts: Vec::new(),
});
}
let branch_name = format!("cargo-rail-sync-{}", self.config.crate_name);
let branch_exists = self.ctx.git()?.git().branch_exists(&branch_name)?;
let pr_branch = if branch_exists {
progress!(" PR branch '{}' already exists, checking state...", branch_name);
self.ctx.git()?.git().checkout_branch(&branch_name)?;
Some(branch_name)
} else {
progress!(" Creating PR branch: {}", branch_name);
self.ctx.git()?.git().create_and_checkout_branch(&branch_name)?;
Some(branch_name)
};
let mut conflicts = Vec::with_capacity(commits_to_sync.len().min(16));
progress!(" Syncing {} commits from remote...", commits_to_sync.len());
let mut count = 0;
let mut current_mono_head = self.ctx.git()?.git().head_commit()?;
for commit in &commits_to_sync {
let resolution = self.resolve_conflicts_for_commit(commit, &remote_git)?;
let resolved_files: HashSet<&Path> = resolution.conflicts.iter().map(|c| c.file_path.as_path()).collect();
let mono_sha = self.apply_remote_commit_to_mono(
commit,
&remote_git,
&resolved_files,
¤t_mono_head,
&resolution.changed_files,
)?;
if !resolution.conflicts.is_empty() {
conflicts.extend(resolution.conflicts);
}
self.mapping_store.record_mapping(&mono_sha, &commit.sha)?;
count += 1;
current_mono_head = mono_sha; }
let synced_count = count;
self.mapping_store.save(self.ctx.workspace_root())?;
if let Some(branch_name) = pr_branch
&& synced_count > 0
{
progress!(
"\n✅ Synced {} commit{} to branch: {}",
synced_count,
if synced_count == 1 { "" } else { "s" },
branch_name
);
progress!("\n📋 To create a pull request:");
progress!(" git push origin {}", branch_name);
if let Ok(Some(url)) = self.ctx.git()?.git().get_config("remote.origin.url")
&& url.contains("github.com")
{
progress!(
" gh pr create --title \"Sync {} from remote\"",
self.config.crate_name
);
}
progress!();
}
Ok(SyncResult {
commits_synced: synced_count,
conflicts,
})
}
pub fn sync_bidirectional(&mut self) -> RailResult<SyncResult> {
progress!(" Detecting changes...");
let mono_has_changes = self.check_mono_has_changes()?;
let remote_has_changes = self.check_remote_has_changes()?;
match (mono_has_changes, remote_has_changes) {
(true, false) => {
progress!(" Only monorepo has changes");
self.sync_to_remote()
}
(false, true) => {
progress!(" Only remote has changes");
self.sync_from_remote()
}
(true, true) => {
progress!(" Both sides have changes, syncing both directions");
let to_remote = self.sync_to_remote()?;
let from_remote = self.sync_from_remote()?;
Ok(SyncResult {
commits_synced: to_remote.commits_synced + from_remote.commits_synced,
conflicts: from_remote.conflicts,
})
}
(false, false) => {
progress!(" No changes on either side");
Ok(SyncResult {
commits_synced: 0,
conflicts: Vec::new(),
})
}
}
}
fn find_last_synced_mono_commit(&self) -> RailResult<Option<String>> {
let commits = self.ctx.git()?.git().commit_history(Some(100))?;
for commit in commits {
if self.mapping_store.has_mapping(&commit.sha) {
return Ok(Some(commit.sha));
}
}
Ok(None)
}
fn find_last_synced_remote_commit(&self, remote_git: &SystemGit) -> RailResult<Option<String>> {
let commits = remote_git.commit_history(Some(100))?;
for commit in commits {
if self.mapping_store.has_reverse_mapping(&commit.sha) {
return Ok(Some(commit.sha));
}
}
Ok(None)
}
fn apply_mono_commit_to_remote(
&self,
commit: &crate::git::CommitInfo,
remote_git: &SystemGit,
current_remote_head: &str,
) -> RailResult<String> {
let changed_files = self.ctx.git()?.git().get_changed_files(&commit.sha)?;
let relevant_files: Vec<_> = changed_files
.into_iter()
.filter(|(path, _)| {
let path_str = path.to_string_lossy();
let should_exclude = path_str.contains("/target/") || path_str.contains("\\target\\");
self.mono_path_in_scope(path) && !should_exclude
})
.collect();
let (deletions, modifications): (Vec<_>, Vec<_>) =
relevant_files.iter().partition(|(_, change_type)| *change_type == 'D');
for (mono_path, _) in &deletions {
let remote_path = self.map_mono_path_to_remote(mono_path)?;
let full_remote_path = self.config.target_repo_path.join(&remote_path);
if full_remote_path.exists() {
std::fs::remove_file(&full_remote_path)?;
}
}
let bulk_items: Vec<(&str, &Path)> = modifications
.iter()
.map(|(path, _)| (commit.sha.as_str(), path.as_path()))
.collect();
let file_contents = if !bulk_items.is_empty() {
self.ctx.git()?.git().read_files_bulk(&bulk_items)?
} else {
vec![]
};
for (idx, (mono_path, _)) in modifications.iter().enumerate() {
let content = &file_contents[idx];
let remote_path = self.map_mono_path_to_remote(mono_path)?;
let full_remote_path = self.config.target_repo_path.join(&remote_path);
if let Some(parent) = full_remote_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&full_remote_path, content)?;
if mono_path.file_name() == Some(std::ffi::OsStr::new("Cargo.toml")) {
let content = std::fs::read_to_string(&full_remote_path)?;
let target_has_workspace =
self.config.mode == SplitMode::Combined && self.config.workspace_mode == WorkspaceMode::Workspace;
let context = TransformContext {
crate_name: self.config.crate_name.clone(),
workspace_root: self.ctx.workspace_root().to_path_buf(),
target_has_workspace,
};
let transformed = self.transform.transform_to_split(&content, &context)?;
std::fs::write(&full_remote_path, transformed)?;
}
}
let message = format!("{}\n\nRail-Origin: mono@{}", commit.message.trim(), commit.sha);
let parent_shas = vec![current_remote_head.to_string()];
let new_commit_sha = remote_git.create_commit_with_metadata(
&message,
&commit.author,
&commit.author_email,
commit.timestamp,
&parent_shas,
)?;
Ok(new_commit_sha)
}
fn apply_remote_commit_to_mono(
&self,
commit: &crate::git::CommitInfo,
remote_git: &SystemGit,
resolved_files: &HashSet<&Path>,
current_mono_head: &str,
changed_files: &[(PathBuf, char)], ) -> RailResult<String> {
let relevant_files: Vec<_> = changed_files
.iter()
.filter_map(|(remote_path, change_type)| {
let mono_path = self.map_remote_path_to_mono(remote_path).ok()?;
let path_str = mono_path.to_string_lossy();
let should_exclude = path_str.contains("/target/") || path_str.contains("\\target\\");
if should_exclude {
return None;
}
if resolved_files.contains(mono_path.as_path()) {
progress!(" Skipping {} (already resolved)", mono_path.display());
return None;
}
Some((remote_path, mono_path, change_type))
})
.collect();
let (deletions, modifications): (Vec<_>, Vec<_>) = relevant_files
.iter()
.partition(|(_, _, change_type)| **change_type == 'D');
for (_, mono_path, _) in &deletions {
let full_mono_path = self.ctx.workspace_root().join(mono_path);
if full_mono_path.exists() {
std::fs::remove_file(&full_mono_path)?;
}
}
let bulk_items: Vec<(&str, &Path)> = modifications
.iter()
.map(|(remote_path, _, _)| (commit.sha.as_str(), (*remote_path).as_path()))
.collect();
let file_contents = if !bulk_items.is_empty() {
remote_git.read_files_bulk(&bulk_items)?
} else {
vec![]
};
for (idx, (remote_path, mono_path, _)) in modifications.iter().enumerate() {
let content = &file_contents[idx];
let full_mono_path = self.ctx.workspace_root().join(mono_path);
if let Some(parent) = full_mono_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&full_mono_path, content)?;
if remote_path.file_name() == Some(std::ffi::OsStr::new("Cargo.toml")) {
let content = std::fs::read_to_string(&full_mono_path)?;
let context = TransformContext {
crate_name: self.config.crate_name.clone(),
workspace_root: self.ctx.workspace_root().to_path_buf(),
target_has_workspace: true,
};
let transformed = self.transform.transform_to_mono(&content, &context)?;
std::fs::write(&full_mono_path, transformed)?;
}
}
let message = format!("{}\n\nRail-Origin: remote@{}", commit.message.trim(), commit.sha);
let parent_shas = vec![current_mono_head.to_string()];
let new_commit_sha = self.ctx.git()?.git().create_commit_with_metadata(
&message,
&commit.author,
&commit.author_email,
commit.timestamp,
&parent_shas,
)?;
Ok(new_commit_sha)
}
fn map_mono_path_to_remote(&self, mono_path: &Path) -> RailResult<PathBuf> {
match self.config.mode {
SplitMode::Single => {
let crate_path = self
.config
.crate_paths
.first()
.ok_or_else(|| crate::error::RailError::message("single-mode sync requires exactly one crate path"))?;
Ok(mono_path.strip_prefix(crate_path)?.to_path_buf())
}
SplitMode::Combined => {
Ok(mono_path.to_path_buf())
}
}
}
fn map_remote_path_to_mono(&self, remote_path: &Path) -> RailResult<PathBuf> {
match self.config.mode {
SplitMode::Single => {
let crate_path = self
.config
.crate_paths
.first()
.ok_or_else(|| crate::error::RailError::message("single-mode sync requires exactly one crate path"))?;
Ok(crate_path.join(remote_path))
}
SplitMode::Combined => {
Ok(remote_path.to_path_buf())
}
}
}
fn resolve_conflicts_for_commit(
&self,
remote_commit: &crate::git::CommitInfo,
remote_git: &SystemGit,
) -> RailResult<ConflictResolutionResult> {
let changed_files = remote_git.get_changed_files(&remote_commit.sha)?;
let last_synced = self.find_last_synced_mono_commit()?;
let mono_changed_paths: std::collections::HashSet<PathBuf> = if let Some(ref last) = last_synced {
self
.ctx
.git()?
.git()
.get_changed_files_between(last, Some("HEAD"))?
.into_iter()
.map(|(path, _)| path)
.collect()
} else {
std::collections::HashSet::new()
};
let mut conflicting_files = Vec::with_capacity(changed_files.len());
for (remote_path, _) in &changed_files {
let mono_path = self.map_remote_path_to_mono(remote_path)?;
let full_mono_path = self.ctx.workspace_root().join(&mono_path);
if !full_mono_path.exists() {
continue;
}
let mono_modified = mono_changed_paths.contains(&mono_path);
if !mono_modified {
continue;
}
conflicting_files.push((remote_path.clone(), mono_path, full_mono_path));
}
let mut conflicts = Vec::with_capacity(conflicting_files.len());
let base_items: Vec<(&str, &Path)> = if let Some(ref sha) = last_synced {
conflicting_files
.iter()
.map(|(_, mono_path, _)| (sha.as_str(), mono_path.as_path()))
.collect()
} else {
vec![]
};
let incoming_items: Vec<(&str, &Path)> = conflicting_files
.iter()
.map(|(remote_path, _, _)| (remote_commit.sha.as_str(), remote_path.as_path()))
.collect();
let base_contents = if !base_items.is_empty() {
self.ctx.git()?.git().read_files_bulk(&base_items)?
} else {
vec![Vec::new(); conflicting_files.len()]
};
let incoming_contents = if !incoming_items.is_empty() {
remote_git.read_files_bulk(&incoming_items)?
} else {
vec![]
};
for (idx, (_, mono_path, full_mono_path)) in conflicting_files.iter().enumerate() {
let base_content = if idx < base_contents.len() {
&base_contents[idx]
} else {
&Vec::new()
};
let incoming_content = &incoming_contents[idx];
match self
.conflict_resolver
.resolve_file(full_mono_path, base_content, incoming_content)
{
Ok(crate::sync::conflict::MergeResult::Success) => {
progress!(" ✅ Auto-merged {}", mono_path.display());
conflicts.push(ConflictInfo {
file_path: mono_path.clone(),
});
}
Ok(crate::sync::conflict::MergeResult::Conflicts(_paths)) => {
conflicts.push(ConflictInfo {
file_path: mono_path.clone(),
});
}
Ok(crate::sync::conflict::MergeResult::Failed(_msg)) => {
conflicts.push(ConflictInfo {
file_path: mono_path.clone(),
});
}
Err(_e) => {
conflicts.push(ConflictInfo {
file_path: mono_path.clone(),
});
}
}
}
Ok(ConflictResolutionResult {
conflicts,
changed_files,
})
}
fn check_mono_has_changes(&self) -> RailResult<bool> {
let last_synced = self.find_last_synced_mono_commit()?;
let new_commits =
self
.ctx
.git()?
.git()
.get_commits_touching_paths(&self.config.crate_paths, last_synced.as_deref(), "HEAD")?;
Ok(
new_commits
.into_iter()
.any(|commit| !commit.message.contains("Rail-Origin: remote@")),
)
}
fn check_remote_has_changes(&self) -> RailResult<bool> {
let remote_git = SystemGit::open(&self.config.target_repo_path)?;
if !utils::is_local_path(&self.config.remote_url) {
remote_git.fetch_from_remote("origin")?;
}
let last_synced = self.find_last_synced_remote_commit(&remote_git)?;
let branch_ref = self.get_branch_ref();
let new_commits = if let Some(ref last) = last_synced {
remote_git.get_commits_touching_path(Path::new("."), Some(last), &branch_ref)?
} else {
remote_git.get_commits_touching_path(Path::new("."), None, &branch_ref)?
};
let relevant_commits: Vec<_> = new_commits
.into_iter()
.filter(|c| !c.message.contains("Rail-Origin: mono@"))
.collect();
Ok(!relevant_commits.is_empty())
}
}