use crate::cargo::{CargoTransform, TransformContext};
use crate::config::{SplitMode, WorkspaceMode};
use crate::error::{GitError, RailError, RailResult, ResultExt};
use crate::git::git_cmd_for_path;
use crate::git::mappings::MappingStore;
use crate::git::{CommitInfo, SystemGit};
use crate::progress;
use crate::utils;
use crate::workspace::WorkspaceContext;
use crate::workspace::files::{AuxiliaryFiles, ProjectFiles};
use glob::Pattern;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct SplitParams {
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: Option<String>,
pub include: Vec<String>,
pub exclude: Vec<String>,
}
type PrefetchedFiles = Vec<(PathBuf, Vec<u8>)>;
const PREFETCH_WINDOW_SIZE: usize = 50;
struct RecreateCommitParams<'a> {
commit: &'a CommitInfo,
crate_paths: &'a [PathBuf],
target_repo_path: &'a Path,
crate_name: &'a str,
mode: &'a SplitMode,
workspace_mode: &'a WorkspaceMode,
mapping_store: &'a MappingStore,
last_recreated_sha: Option<&'a str>,
prefetched_files: Option<&'a PrefetchedFiles>,
}
struct CommitParams<'a> {
repo_path: &'a Path,
message: &'a str,
author_name: &'a str,
author_email: &'a str,
committer_name: &'a str,
committer_email: &'a str,
timestamp: i64,
parent_shas: &'a [String],
}
pub struct SplitEngine<'a> {
ctx: &'a WorkspaceContext,
transform: CargoTransform,
}
impl<'a> SplitEngine<'a> {
pub fn new(ctx: &'a WorkspaceContext) -> RailResult<Self> {
let transformer = CargoTransform::new(ctx.cargo.metadata().clone());
Ok(Self {
ctx,
transform: transformer,
})
}
fn should_exclude(path: &str, exclude_patterns: &[Pattern]) -> bool {
for pattern in exclude_patterns {
if pattern.matches(path) {
return true;
}
}
false
}
fn compile_patterns(patterns: &[String]) -> Vec<Pattern> {
patterns.iter().filter_map(|p| Pattern::new(p).ok()).collect()
}
fn find_included_files(workspace_root: &Path, include_patterns: &[String]) -> RailResult<Vec<PathBuf>> {
use std::collections::HashSet;
let mut included = HashSet::new();
if include_patterns.is_empty() {
return Ok(Vec::new());
}
for pattern_str in include_patterns {
let full_pattern = workspace_root.join(pattern_str);
let glob_pattern = full_pattern.to_string_lossy();
if let Ok(paths) = glob::glob(&glob_pattern) {
for path_result in paths.flatten() {
if path_result.is_file() {
let path_str = path_result.to_string_lossy();
if path_str.contains("/.git/") || path_str.contains("\\.git\\") {
continue;
}
if let Ok(rel) = path_result.strip_prefix(workspace_root) {
included.insert(rel.to_path_buf());
}
}
}
}
}
Ok(included.into_iter().collect())
}
fn walk_filtered_history(&self, paths: &[PathBuf]) -> RailResult<Vec<CommitInfo>> {
progress!(" Walking commit history to find commits touching crate...");
let filtered_commits = self.ctx.git.git().get_commits_touching_paths(paths, None, "HEAD")?;
progress!(
" Found {} total commits that touch the crate paths",
filtered_commits.len()
);
Ok(filtered_commits)
}
fn prefetch_commit_files(
&self,
commits: &[&CommitInfo],
crate_paths: &[PathBuf],
) -> FxHashMap<String, PrefetchedFiles> {
let git = self.ctx.git.git();
let paths_arc = Arc::new(crate_paths.to_vec());
commits
.par_iter()
.filter_map(|commit| {
let paths = Arc::clone(&paths_arc);
let mut all_files = Vec::with_capacity(32);
for crate_path in paths.iter() {
match git.collect_tree_files(&commit.sha, crate_path) {
Ok(files) => all_files.extend(files),
Err(_) => {
return None;
}
}
}
Some((commit.sha.clone(), all_files))
})
.collect()
}
fn apply_manifest_transform(
&self,
manifest_path: &Path,
crate_name: &str,
target_has_workspace: bool,
) -> RailResult<()> {
if !manifest_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(manifest_path)?;
let context = TransformContext {
crate_name: crate_name.to_string(),
workspace_root: self.ctx.workspace_root().to_path_buf(),
target_has_workspace,
};
let transformed = self.transform.transform_to_split(&content, &context)?;
std::fs::write(manifest_path, transformed)?;
Ok(())
}
fn recreate_commit_in_target(&self, params: &RecreateCommitParams) -> RailResult<Option<String>> {
let all_files: std::borrow::Cow<'_, PrefetchedFiles> = if let Some(prefetched) = params.prefetched_files {
std::borrow::Cow::Borrowed(prefetched)
} else {
let mut files = Vec::with_capacity(params.crate_paths.len() * 32);
for crate_path in params.crate_paths {
let collected = self.ctx.git.git().collect_tree_files(¶ms.commit.sha, crate_path)?;
files.extend(collected);
}
std::borrow::Cow::Owned(files)
};
if all_files.is_empty() {
return Ok(None);
}
for (file_path, content_bytes) in all_files.iter() {
let target_path = match params.mode {
SplitMode::Single => {
let relative = params
.crate_paths
.iter()
.find_map(|crate_path| file_path.strip_prefix(crate_path).ok().map(Path::to_path_buf))
.unwrap_or_else(|| file_path.clone());
params.target_repo_path.join(relative)
}
SplitMode::Combined => {
params.target_repo_path.join(file_path)
}
};
if let Some(parent) = target_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target_path, content_bytes)?;
if file_path.file_name() == Some(std::ffi::OsStr::new("Cargo.toml")) {
let target_has_workspace =
*params.mode == SplitMode::Combined && *params.workspace_mode == WorkspaceMode::Workspace;
self.apply_manifest_transform(&target_path, params.crate_name, target_has_workspace)?;
}
}
let mut mapped_parents: Vec<String> = params
.commit
.parent_shas
.iter()
.filter_map(|parent_sha| params.mapping_store.get_mapping(parent_sha).ok().flatten())
.collect();
if mapped_parents.is_empty()
&& let Some(ref sha) = params.last_recreated_sha
{
mapped_parents.push(sha.to_string());
}
let sha = self.create_git_commit(&CommitParams {
repo_path: params.target_repo_path,
message: ¶ms.commit.message,
author_name: ¶ms.commit.author,
author_email: ¶ms.commit.author_email,
committer_name: ¶ms.commit.committer,
committer_email: ¶ms.commit.committer_email,
timestamp: params.commit.timestamp,
parent_shas: &mapped_parents,
})?;
Ok(Some(sha))
}
fn create_git_commit(&self, params: &CommitParams) -> RailResult<String> {
let add_output = Self::run_git_in_repo(params.repo_path, &["add", "-A"])?;
if !add_output.status.success() {
return Err(RailError::Git(GitError::CommandFailed {
command: "git add".to_string(),
stderr: String::from_utf8_lossy(&add_output.stderr).trim().to_string(),
}));
}
let output = Self::run_git_in_repo(params.repo_path, &["write-tree"])?;
if !output.status.success() {
return Err(RailError::Git(GitError::CommandFailed {
command: "git write-tree".to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
}));
}
let tree_sha = String::from_utf8(output.stdout)?.trim().to_string();
let author_date = format!("{} +0000", params.timestamp);
let commit_date = format!("{} +0000", params.timestamp);
let mut cmd = git_cmd_for_path(params.repo_path);
cmd
.env("GIT_AUTHOR_NAME", params.author_name)
.env("GIT_AUTHOR_EMAIL", params.author_email)
.env("GIT_AUTHOR_DATE", &author_date)
.env("GIT_COMMITTER_NAME", params.committer_name)
.env("GIT_COMMITTER_EMAIL", params.committer_email)
.env("GIT_COMMITTER_DATE", &commit_date)
.arg("commit-tree")
.arg(&tree_sha)
.arg("-m")
.arg(params.message);
for parent in params.parent_shas {
cmd.arg("-p").arg(parent);
}
let output = cmd.output().context("Failed to run git commit-tree")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RailError::Git(GitError::CommandFailed {
command: "git commit-tree".to_string(),
stderr: stderr.to_string(),
}));
}
let commit_sha = String::from_utf8(output.stdout)?.trim().to_string();
let update_output = Self::run_git_in_repo(params.repo_path, &["update-ref", "HEAD", &commit_sha])?;
if !update_output.status.success() {
return Err(RailError::Git(GitError::CommandFailed {
command: "git update-ref HEAD".to_string(),
stderr: String::from_utf8_lossy(&update_output.stderr).trim().to_string(),
}));
}
Ok(commit_sha)
}
fn run_git_in_repo(repo_path: &Path, args: &[&str]) -> RailResult<std::process::Output> {
let mut cmd = git_cmd_for_path(repo_path);
cmd.args(args);
cmd
.output()
.with_context(|| format!("Failed to execute git {}", args.join(" ")))
}
fn check_remote_exists(&self, remote_url: &str) -> RailResult<bool> {
self.ctx.git.git().ls_remote_has_content(remote_url)
}
pub fn split(&self, config: &SplitParams) -> RailResult<()> {
progress!("🚂 Splitting crate: {}", config.crate_name);
progress!(" Mode: {:?}", config.mode);
progress!(" Target: {}", config.target_repo_path.display());
let exclude_patterns = Self::compile_patterns(&config.exclude);
if !config.include.is_empty() {
progress!(" Include patterns: {} configured", config.include.len());
}
if !config.exclude.is_empty() {
progress!(" Exclude patterns: {} configured", config.exclude.len());
}
let target_exists = config.target_repo_path.join(".git").exists();
if let Some(ref remote_url) = config.remote_url {
let remote_exists = self.check_remote_exists(remote_url)?;
if remote_exists && !target_exists {
return Err(RailError::with_help(
format!("Split already exists at {}", remote_url),
format!(
"Split is a one-time operation. To update the split repo, use:\n \
cargo rail sync {}\n\n\
This will sync new commits from the monorepo to the split repo.",
config.crate_name
),
));
}
}
self.ensure_target_repo(&config.target_repo_path)?;
let aux_files = AuxiliaryFiles::discover(self.ctx.workspace_root())?;
progress!(" Found {} workspace config files", aux_files.count());
let project_files = ProjectFiles::discover(self.ctx.workspace_root(), &config.crate_paths)?;
progress!(" Found {} project files (README, LICENSE)", project_files.count());
let additional_files = Self::find_included_files(self.ctx.workspace_root(), &config.include)?;
if !additional_files.is_empty() {
progress!(
" Found {} additional files from include patterns",
additional_files.len()
);
}
let mut mapping_store = MappingStore::new(config.crate_name.clone());
mapping_store.load(self.ctx.workspace_root())?;
if target_exists {
mapping_store.load(&config.target_repo_path)?;
}
let filtered_commits = self.walk_filtered_history(&config.crate_paths)?;
let already_mapped_count = filtered_commits
.iter()
.filter(|c| mapping_store.has_mapping(&c.sha))
.count();
if already_mapped_count > 0 {
progress!(" Found {} commits already split (will skip)", already_mapped_count);
}
if already_mapped_count == filtered_commits.len() && !filtered_commits.is_empty() {
progress!("\n✅ Split already up-to-date!");
progress!(" All {} commits have been split previously.", filtered_commits.len());
progress!(" Target repo: {}", config.target_repo_path.display());
return Ok(());
}
if filtered_commits.is_empty() {
progress!(" No commits found that touch the crate paths");
progress!(" Falling back to current state copy...");
match config.mode {
SplitMode::Single => {
let crate_path = &config.crate_paths[0];
self.split_single_crate(crate_path, &config.target_repo_path, &aux_files, &config.crate_name)?;
}
SplitMode::Combined => {
self.split_combined_crates(
&config.crate_paths,
&config.target_repo_path,
&aux_files,
&config.crate_name,
&config.workspace_mode,
)?;
}
}
} else {
progress!(" Processing {} commits...", filtered_commits.len());
let mut last_recreated_sha: Option<String> = None;
let mut skipped_commits = 0usize;
let skipped_already_mapped = already_mapped_count;
if target_exists && already_mapped_count > 0 {
for commit in filtered_commits.iter().rev() {
if let Ok(Some(target_sha)) = mapping_store.get_mapping(&commit.sha) {
last_recreated_sha = Some(target_sha);
break;
}
}
}
let commits_to_process: Vec<&CommitInfo> = filtered_commits
.iter()
.filter(|c| !mapping_store.has_mapping(&c.sha))
.collect();
let total_new = commits_to_process.len();
let use_parallel = total_new > 5;
for (window_idx, window) in commits_to_process.chunks(PREFETCH_WINDOW_SIZE).enumerate() {
let prefetched_files: FxHashMap<String, PrefetchedFiles> = if use_parallel {
if window_idx == 0 {
if total_new > PREFETCH_WINDOW_SIZE {
progress!(
" Prefetching in windows of {} commits to bound memory...",
PREFETCH_WINDOW_SIZE
);
} else {
progress!(" Prefetching file contents in parallel...");
}
}
self.prefetch_commit_files(window, &config.crate_paths)
} else {
FxHashMap::default()
};
for (idx_in_window, commit) in window.iter().enumerate() {
let overall_idx = window_idx * PREFETCH_WINDOW_SIZE + idx_in_window + 1;
if overall_idx.is_multiple_of(10) || overall_idx == total_new {
progress!(" Progress: {}/{} new commits", overall_idx, total_new);
}
let prefetched = prefetched_files.get(&commit.sha);
let maybe_sha = self.recreate_commit_in_target(&RecreateCommitParams {
commit,
crate_paths: &config.crate_paths,
target_repo_path: &config.target_repo_path,
crate_name: &config.crate_name,
mode: &config.mode,
workspace_mode: &config.workspace_mode,
mapping_store: &mapping_store,
last_recreated_sha: last_recreated_sha.as_deref(),
prefetched_files: prefetched,
})?;
let Some(new_sha) = maybe_sha else {
skipped_commits += 1;
continue;
};
mapping_store.record_mapping(&commit.sha, &new_sha)?;
last_recreated_sha = Some(new_sha);
}
}
if skipped_commits > 0 || skipped_already_mapped > 0 {
if skipped_commits > 0 {
progress!(
" Skipped {} commits where path didn't exist (dirty history)",
skipped_commits
);
}
if skipped_already_mapped > 0 {
progress!(
" Skipped {} commits already split (idempotent)",
skipped_already_mapped
);
}
}
if config.mode == SplitMode::Combined && config.workspace_mode == WorkspaceMode::Workspace {
progress!(" Creating workspace Cargo.toml...");
self.create_workspace_cargo_toml(&config.crate_paths, &config.target_repo_path)?;
}
let has_files = !aux_files.is_empty() || project_files.count() > 0 || !additional_files.is_empty();
if has_files {
progress!(" Copying workspace configs and project files...");
aux_files.copy_to_split(self.ctx.workspace_root(), &config.target_repo_path)?;
project_files.copy_to_split(self.ctx.workspace_root(), &config.target_repo_path)?;
if !additional_files.is_empty() {
progress!(
" Copying {} additional files from include patterns...",
additional_files.len()
);
for rel_path in &additional_files {
let source = self.ctx.workspace_root().join(rel_path);
let target = config.target_repo_path.join(rel_path);
let path_str = rel_path.to_string_lossy();
if Self::should_exclude(&path_str, &exclude_patterns) {
continue;
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
if source.exists() && source.is_file() {
std::fs::copy(&source, &target)?;
}
}
}
let target_git = SystemGit::open(&config.target_repo_path)?;
target_git.stage_all()?;
if target_git.has_staged_changes()? {
progress!(" Creating commit for auxiliary files");
target_git.commit("Add workspace configs and project files")?;
}
}
}
mapping_store.save(self.ctx.workspace_root())?;
mapping_store.save(&config.target_repo_path)?;
if let Some(ref remote_url) = config.remote_url {
if !remote_url.is_empty() && !utils::is_local_path(remote_url) {
progress!("\n🚀 Pushing to remote...");
let target_git = SystemGit::open(&config.target_repo_path)?;
if !target_git.has_remote("origin")? {
progress!(" Adding remote 'origin': {}", remote_url);
target_git.add_remote("origin", remote_url)?;
} else {
progress!(" Remote 'origin' already exists");
}
target_git.push_to_remote("origin", &config.branch)?;
mapping_store.push_notes(&config.target_repo_path, "origin")?;
progress!(" ✅ Pushed to {}", remote_url);
} else {
progress!("\n💾 Split repository created locally");
if utils::is_local_path(remote_url) {
progress!(" Note: Remote is a local path, skipping push");
progress!(
" Local testing mode - split repo at: {}",
config.target_repo_path.display()
);
} else {
progress!(" No remote URL configured");
}
progress!("\n To push to a real remote later:");
progress!(" cd {}", config.target_repo_path.display());
progress!(" git remote add origin <url>");
progress!(" git push -u origin {}", config.branch);
}
} else {
progress!("\n⚠️ No remote URL configured - repository created locally only");
progress!(" To push manually:");
progress!(" cd {}", config.target_repo_path.display());
progress!(" git remote add origin <url>");
progress!(" git push -u origin {}", config.branch);
}
progress!("\n✅ Split complete!");
progress!(" Target repo: {}", config.target_repo_path.display());
Ok(())
}
fn ensure_target_repo(&self, target_path: &Path) -> RailResult<()> {
if !target_path.exists() {
std::fs::create_dir_all(target_path)
.with_context(|| format!("Failed to create target directory: {}", target_path.display()))?;
}
let git_dir = target_path.join(".git");
if !git_dir.exists() {
progress!(" Initializing git repository at {}", target_path.display());
crate::git::init_repo(target_path, "main")?;
self.configure_git_identity(target_path)?;
}
Ok(())
}
fn configure_git_identity(&self, target_path: &Path) -> RailResult<()> {
let user_name = self.ctx.git.git().get_config("user.name")?.unwrap_or_default();
let user_email = self.ctx.git.git().get_config("user.email")?.unwrap_or_default();
let name = if user_name.is_empty() { "Cargo Rail" } else { &user_name };
let email = if user_email.is_empty() {
"cargo-rail@localhost"
} else {
&user_email
};
let target_git = SystemGit::open(target_path)?;
target_git.set_config("user.name", name)?;
target_git.set_config("user.email", email)?;
Ok(())
}
fn split_single_crate(
&self,
crate_path: &Path,
target_repo_path: &Path,
aux_files: &AuxiliaryFiles,
crate_name: &str,
) -> RailResult<()> {
let source_path = self.ctx.workspace_root().join(crate_path);
progress!(" Copying source files from {}", crate_path.display());
self.copy_directory_recursive(&source_path, target_repo_path)?;
progress!(" Transforming Cargo.toml");
let manifest_path = target_repo_path.join("Cargo.toml");
self.apply_manifest_transform(&manifest_path, crate_name, false)?;
if !aux_files.is_empty() {
progress!(" Copying auxiliary files");
aux_files.copy_to_split(self.ctx.workspace_root(), target_repo_path)?;
}
Ok(())
}
fn split_combined_crates(
&self,
crate_paths: &[PathBuf],
target_repo_path: &Path,
aux_files: &AuxiliaryFiles,
crate_name: &str,
workspace_mode: &WorkspaceMode,
) -> RailResult<()> {
let target_has_workspace = *workspace_mode == WorkspaceMode::Workspace;
for crate_path in crate_paths {
let source_path = self.ctx.workspace_root().join(crate_path);
let target_path = target_repo_path.join(crate_path);
progress!(" Copying {} to {}", crate_path.display(), crate_path.display());
if let Some(parent) = target_path.parent() {
std::fs::create_dir_all(parent)?;
}
self.copy_directory_recursive(&source_path, &target_path)?;
let manifest_path = target_path.join("Cargo.toml");
self.apply_manifest_transform(&manifest_path, crate_name, target_has_workspace)?;
}
if !aux_files.is_empty() {
progress!(" Copying auxiliary files");
aux_files.copy_to_split(self.ctx.workspace_root(), target_repo_path)?;
}
Ok(())
}
fn create_workspace_cargo_toml(&self, crate_paths: &[PathBuf], target_repo_path: &Path) -> RailResult<()> {
let members: Vec<String> = crate_paths.iter().map(|p| p.to_string_lossy().to_string()).collect();
let source_workspace_toml = self.ctx.workspace_root().join("Cargo.toml");
let source_content = std::fs::read_to_string(&source_workspace_toml).with_context(|| {
format!(
"Failed to read workspace Cargo.toml from {}",
source_workspace_toml.display()
)
})?;
let mut doc: toml_edit::DocumentMut = source_content
.parse()
.map_err(|e| RailError::message(format!("Failed to parse workspace Cargo.toml: {}", e)))?;
if let Some(workspace) = doc.get_mut("workspace")
&& let Some(table) = workspace.as_table_mut()
{
let mut members_array = toml_edit::Array::new();
for member in &members {
members_array.push(member.as_str());
}
table.insert("members", toml_edit::value(members_array));
table.remove("exclude");
let members_set: std::collections::HashSet<&str> = members.iter().map(|s| s.as_str()).collect();
if let Some(default_members) = table.get_mut("default-members")
&& let Some(arr) = default_members.as_array_mut()
{
arr.retain(|item| item.as_str().map(|s| members_set.contains(s)).unwrap_or(false));
}
if table
.get("default-members")
.and_then(|d| d.as_array())
.map(|a| a.is_empty())
.unwrap_or(false)
{
table.remove("default-members");
}
table.remove("dependencies");
}
let members_set: std::collections::HashSet<&str> = members.iter().map(|s| s.as_str()).collect();
if let Some(profile) = doc.get_mut("profile").and_then(|p| p.as_table_mut()) {
for (_, profile_section) in profile.iter_mut() {
if let Some(profile_table) = profile_section.as_table_mut() {
if let Some(pkg) = profile_table.get_mut("package").and_then(|p| p.as_table_mut()) {
let pkg_names: Vec<String> = pkg.iter().map(|(k, _)| k.to_string()).collect();
for pkg_name in pkg_names {
if !members_set.contains(pkg_name.as_str()) {
pkg.remove(&pkg_name);
}
}
}
if profile_table
.get("package")
.and_then(|p| p.as_table())
.map(|t| t.is_empty())
.unwrap_or(false)
{
profile_table.remove("package");
}
}
}
}
doc.remove("package");
doc.remove("dependencies");
doc.remove("dev-dependencies");
doc.remove("build-dependencies");
let target_toml = target_repo_path.join("Cargo.toml");
std::fs::write(&target_toml, doc.to_string())?;
progress!(" Created workspace Cargo.toml with {} members", members.len());
Ok(())
}
fn copy_directory_recursive(&self, source: &Path, target: &Path) -> RailResult<()> {
copy_directory_recursive_impl(source, target)
}
}
fn copy_directory_recursive_impl(source: &Path, target: &Path) -> RailResult<()> {
if !source.exists() {
return Err(RailError::message(format!(
"Source path does not exist: {}",
source.display()
)));
}
if source.is_file() {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(source, target)?;
return Ok(());
}
std::fs::create_dir_all(target)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let file_type = entry.file_type()?;
let file_name = entry.file_name();
if file_name == ".git" {
continue;
}
let source_path = entry.path();
let target_path = target.join(&file_name);
if file_type.is_dir() {
copy_directory_recursive_impl(&source_path, &target_path)?;
} else {
std::fs::copy(&source_path, &target_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn find_git_root() -> PathBuf {
let current_dir = std::env::current_dir().unwrap();
match SystemGit::open(¤t_dir) {
Ok(git) => git.worktree_root.clone(),
Err(_) => current_dir,
}
}
#[test]
fn test_copy_directory_recursive() {
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
let target = temp.path().join("target");
fs::create_dir_all(source.join("src")).unwrap();
fs::write(source.join("Cargo.toml"), "test").unwrap();
fs::write(source.join("src/lib.rs"), "pub fn test() {}").unwrap();
fs::create_dir(source.join(".git")).unwrap();
let workspace_root = find_git_root();
let ctx = WorkspaceContext::build(&workspace_root).unwrap();
let engine = SplitEngine::new(&ctx).unwrap();
engine.copy_directory_recursive(&source, &target).unwrap();
assert!(target.join("Cargo.toml").exists());
assert!(target.join("src/lib.rs").exists());
assert!(!target.join(".git").exists());
}
}