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::split::SplitPathCapabilities;
use crate::sync::conflict::{ConflictClass, ConflictInfo, ConflictResolver, ConflictStrategy};
use crate::utils;
use crate::workspace::WorkspaceContext;
use serde::{Deserialize, Serialize};
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,
pub path_capabilities: SplitPathCapabilities,
}
pub struct SyncResult {
pub commits_synced: usize,
pub conflicts: Vec<ConflictInfo>,
pub status: SyncStatus,
pub conflict_receipt: Option<PathBuf>,
}
impl Default for SyncResult {
fn default() -> Self {
Self {
commits_synced: 0,
conflicts: Vec::new(),
status: SyncStatus::Complete,
conflict_receipt: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncStatus {
Complete,
Conflicted,
}
#[derive(Debug, Clone)]
pub enum SyncDirection {
MonoToRemote,
RemoteToMono,
Both,
None,
}
pub struct ConflictResolutionResult {
pub conflicts: Vec<ConflictInfo>,
pub resolved_files: Vec<PathBuf>,
pub changed_files: Vec<(PathBuf, char)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SyncConflictReceipt {
schema_version: u32,
status: String,
crate_name: String,
branch: String,
expected_head: String,
remote_commit: String,
message: String,
author: String,
author_email: String,
timestamp: i64,
commit_paths: Vec<PathBuf>,
conflicts: Vec<ConflictInfo>,
resulting_commit: Option<String>,
}
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 target = config.path_capabilities.authorize_target(&config.target_repo_path)?;
if target != config.path_capabilities.target_root() {
return Err(crate::error::RailError::message(
"runtime sync target does not match the validated path capability",
));
}
config.path_capabilities.validate_crate_paths(&config.crate_paths)?;
config.path_capabilities.validate_target_repository()?;
let mapping_store = MappingStore::new(config.crate_name.clone());
let transformer = CargoTransform::new(ctx.cargo.metadata().clone());
let temp_dir = config.path_capabilities.authorize_temporary(Path::new(&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(())
}
pub fn resume_from_receipt(&mut self, receipt_path: &Path) -> RailResult<SyncResult> {
let receipt_path = self.validate_conflict_receipt_path(receipt_path)?;
let bytes = std::fs::read(&receipt_path)?;
let mut receipt: SyncConflictReceipt = serde_json::from_slice(&bytes)
.map_err(|error| crate::error::RailError::message(format!("invalid sync conflict receipt: {}", error)))?;
if receipt.schema_version != 1 || receipt.status != "conflicted" {
return Err(crate::error::RailError::message(
"sync conflict receipt is not an active version-1 conflict",
));
}
if receipt.crate_name != self.config.crate_name {
return Err(crate::error::RailError::message(format!(
"sync receipt is for crate '{}', not '{}'",
receipt.crate_name, self.config.crate_name
)));
}
let git = self.ctx.git()?.git();
let branch = git.current_branch()?;
if branch != receipt.branch {
return Err(crate::error::RailError::with_help(
format!(
"sync resume requires branch '{}'; current branch is '{}'",
receipt.branch, branch
),
format!("git switch {}", receipt.branch),
));
}
let head = git.head_commit()?;
if head != receipt.expected_head {
return Err(crate::error::RailError::with_help(
"sync recovery branch moved after the conflict was recorded",
"inspect the branch history and restart sync; cargo-rail will not commit against an unverified parent",
));
}
for conflict in &receipt.conflicts {
let path = self
.config
.path_capabilities
.authorize_source_mutation(&conflict.file_path)?;
let content = std::fs::read(&path)?;
if contains_conflict_markers(&content) {
return Err(crate::error::RailError::with_help(
format!(
"unresolved conflict markers remain in '{}'",
conflict.file_path.display()
),
"resolve every marker in the receipt before resuming",
));
}
}
let expected = receipt
.commit_paths
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
let unexpected = git
.changed_paths()?
.into_iter()
.filter(|path| !expected.contains(path))
.collect::<Vec<_>>();
if !unexpected.is_empty() {
return Err(crate::error::RailError::with_help(
format!(
"sync resume found unrelated changes: {}",
unexpected
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
"commit or restore unrelated work before resuming",
));
}
let commit = git.create_commit_with_metadata(
&receipt.message,
&receipt.author,
&receipt.author_email,
receipt.timestamp,
std::slice::from_ref(&receipt.expected_head),
&receipt.commit_paths,
)?;
self.mapping_store.record_mapping(&commit, &receipt.remote_commit)?;
self.mapping_store.save(self.ctx.workspace_root())?;
receipt.status = "resolved".to_string();
receipt.resulting_commit = Some(commit);
write_json_atomic(&receipt_path, &receipt)?;
let mut remaining = self.sync_from_remote()?;
remaining.commits_synced += 1;
Ok(remaining)
}
fn write_conflict_receipt(&self, receipt: SyncConflictReceipt) -> RailResult<PathBuf> {
let dir = self.conflict_receipt_dir();
std::fs::create_dir_all(&dir)?;
let nonce = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = dir.join(format!("sync-conflict-{}-{}.json", self.config.crate_name, nonce));
write_json_atomic(&path, &receipt)?;
Ok(path)
}
fn conflict_receipt_dir(&self) -> PathBuf {
self.ctx.workspace_root().join("target/cargo-rail/receipts")
}
fn validate_conflict_receipt_path(&self, receipt_path: &Path) -> RailResult<PathBuf> {
let path = utils::canonicalize_existing(receipt_path)?;
let dir = utils::canonicalize_existing(&self.conflict_receipt_dir())?;
if path.parent().is_none_or(|parent| parent != dir) {
return Err(crate::error::RailError::message(format!(
"sync receipt '{}' is outside the workspace receipt directory",
receipt_path.display()
)));
}
Ok(path)
}
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(),
..SyncResult::default()
});
}
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(),
..SyncResult::default()
})
}
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(),
..SyncResult::default()
});
}
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.resolved_files.iter().map(PathBuf::as_path).collect();
let mut commit_paths = resolution
.changed_files
.iter()
.filter_map(|(remote_path, _)| self.map_remote_path_to_mono(remote_path).ok())
.chain(resolution.resolved_files.iter().cloned())
.collect::<Vec<_>>();
commit_paths.sort();
commit_paths.dedup();
let mono_sha = self.apply_remote_commit_to_mono(
commit,
&remote_git,
&resolved_files,
¤t_mono_head,
&resolution.changed_files,
resolution.conflicts.is_empty(),
)?;
if !resolution.conflicts.is_empty() {
let branch = pr_branch
.as_deref()
.ok_or_else(|| crate::error::RailError::message("conflicted sync has no recovery branch"))?;
let receipt = self.write_conflict_receipt(SyncConflictReceipt {
schema_version: 1,
status: "conflicted".to_string(),
crate_name: self.config.crate_name.clone(),
branch: branch.to_string(),
expected_head: current_mono_head.clone(),
remote_commit: commit.sha.clone(),
message: format!("{}\n\nRail-Origin: remote@{}", commit.message.trim(), commit.sha),
author: commit.author.clone(),
author_email: commit.author_email.clone(),
timestamp: commit.timestamp,
commit_paths,
conflicts: resolution.conflicts.clone(),
resulting_commit: None,
})?;
progress!(" sync paused with unresolved conflicts on branch '{}'", branch);
progress!(" resolve the listed paths, then run:");
progress!(" cargo rail sync --resume {}", receipt.display());
return Ok(SyncResult {
commits_synced: count,
conflicts: resolution.conflicts,
status: SyncStatus::Conflicted,
conflict_receipt: Some(receipt),
});
}
let mono_sha = mono_sha.ok_or_else(|| crate::error::RailError::message("clean sync did not create a commit"))?;
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,
..SyncResult::default()
})
}
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,
status: from_remote.status,
conflict_receipt: from_remote.conflict_receipt,
})
}
(false, false) => {
progress!(" No changes on either side");
Ok(SyncResult {
commits_synced: 0,
conflicts: Vec::new(),
..SyncResult::default()
})
}
}
}
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.path_capabilities.authorize_target(&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.path_capabilities.authorize_target(&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 mut commit_paths = relevant_files
.iter()
.map(|(mono_path, _)| self.map_mono_path_to_remote(mono_path))
.collect::<RailResult<Vec<_>>>()?;
commit_paths.sort();
commit_paths.dedup();
let new_commit_sha = remote_git.create_commit_with_metadata(
&message,
&commit.author,
&commit.author_email,
commit.timestamp,
&parent_shas,
&commit_paths,
)?;
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)], create_commit: bool,
) -> RailResult<Option<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.config.path_capabilities.authorize_source_mutation(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.config.path_capabilities.authorize_source_mutation(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 mut commit_paths = relevant_files
.iter()
.map(|(_, mono_path, _)| mono_path.clone())
.chain(resolved_files.iter().map(|path| (*path).to_path_buf()))
.collect::<Vec<_>>();
commit_paths.sort();
commit_paths.dedup();
if !create_commit {
return Ok(None);
}
let new_commit_sha = self.ctx.git()?.git().create_commit_with_metadata(
&message,
&commit.author,
&commit.author_email,
commit.timestamp,
&parent_shas,
&commit_paths,
)?;
Ok(Some(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.config.path_capabilities.authorize_source_mutation(&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 mut resolved_files = 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());
resolved_files.push(mono_path.clone());
}
Ok(crate::sync::conflict::MergeResult::Conflicts(_paths)) => {
conflicts.push(ConflictInfo {
file_path: mono_path.clone(),
class: ConflictClass::Content,
});
resolved_files.push(mono_path.clone());
}
Ok(crate::sync::conflict::MergeResult::Failed(message)) => {
return Err(crate::error::RailError::with_help(
format!("failed to merge '{}': {}", mono_path.display(), message),
"the sync branch and worktree were preserved; correct the underlying Git merge failure and retry",
));
}
Err(error) => return Err(error.context(format!("merging '{}'", mono_path.display()))),
}
}
Ok(ConflictResolutionResult {
conflicts,
resolved_files,
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())
}
}
fn contains_conflict_markers(content: &[u8]) -> bool {
content
.split(|byte| *byte == b'\n')
.any(|line| line.starts_with(b"<<<<<<<") || line.starts_with(b"=======") || line.starts_with(b">>>>>>>"))
}
fn write_json_atomic(path: &Path, value: &impl Serialize) -> RailResult<()> {
let bytes = serde_json::to_vec_pretty(value)
.map_err(|error| crate::error::RailError::message(format!("failed to serialize sync receipt: {}", error)))?;
let temporary = path.with_extension("json.tmp");
std::fs::write(&temporary, bytes)?;
std::fs::rename(&temporary, path)?;
Ok(())
}
pub fn conflict_receipt_crate(workspace_root: &Path, receipt_path: &Path) -> RailResult<String> {
let path = utils::canonicalize_existing(receipt_path)?;
let dir = utils::canonicalize_existing(&workspace_root.join("target/cargo-rail/receipts"))?;
if path.parent().is_none_or(|parent| parent != dir) {
return Err(crate::error::RailError::message(format!(
"sync receipt '{}' is outside the workspace receipt directory",
receipt_path.display()
)));
}
let receipt: SyncConflictReceipt = serde_json::from_slice(&std::fs::read(path)?)
.map_err(|error| crate::error::RailError::message(format!("invalid sync conflict receipt: {}", error)))?;
Ok(receipt.crate_name)
}