use std::{fs, io::Write, path::Path}; use crate::{ cli::ForkArgs, error::PakkerError, git::{self, VcsType}, model::{ config::Config, fork::{ForkIntegrity, LocalConfig, ParentConfig, RefType, hash_content}, }, }; const PAKKU_DIR: &str = ".pakku"; const PARENT_DIR_NAME: &str = "parent"; fn parent_dir() -> String { format!("{PAKKU_DIR}/{PARENT_DIR_NAME}") } /// Main entry point for fork commands pub fn execute(args: &ForkArgs) -> Result<(), PakkerError> { match &args.subcommand { crate::cli::ForkSubcommand::Init { git_url, from_current, from_path, ref_name, ref_type, remote, } => { execute_init( git_url.clone(), *from_current, from_path.clone(), ref_name.clone(), *ref_type, remote.clone(), ) }, crate::cli::ForkSubcommand::Set { git_url, ref_name, ref_type, remote, } => { execute_set(git_url.clone(), ref_name.clone(), *ref_type, remote.clone()) }, crate::cli::ForkSubcommand::Show => execute_show(), crate::cli::ForkSubcommand::Unset => execute_unset(), crate::cli::ForkSubcommand::Sync => execute_sync(), crate::cli::ForkSubcommand::Promote { projects } => { execute_promote(projects.clone()) }, } } fn validate_git_url(url: &str) -> Result<(), PakkerError> { // Allow network URLs, SSH-style URLs, or local filesystem paths (tests use // local bare repos) if url.starts_with("https://") || url.starts_with("git@") || url.starts_with("ssh://") || url.starts_with("file://") || url.starts_with('/') { Ok(()) } else { Err(PakkerError::Fork(format!( "Invalid git URL: {url}. Expected https://, git@, ssh://, file://, or \ absolute filesystem path." ))) } } fn execute_init( git_url: Option, from_current: bool, from_path: Option, ref_name: Option, ref_type: Option, remote: Option, ) -> Result<(), PakkerError> { let config_dir = Path::new("."); // Validate that pakker.json exists for fork operations let pakker_json_path = config_dir.join("pakker.json"); let pakku_json_path = config_dir.join("pakku.json"); if !pakker_json_path.exists() && pakku_json_path.exists() { return Err(PakkerError::Fork( "Forking is a pakker-specific feature and requires pakker.json. \nFound \ pakku.json but not pakker.json. Please migrate to pakker.json to use \ fork functionality.\nYou can convert your pakku.json to pakker.json by \ renaming the file." .to_string(), )); } let mut local_config = LocalConfig::load(config_dir).unwrap_or_default(); // Check if parent already configured if local_config.parent.is_some() && let Some(parent) = &local_config.parent { return Err(PakkerError::Fork(format!( "Parent already configured: {}", parent.id ))); } // Resolve defaults early to avoid shadowing/confusion let resolved_remote = remote.unwrap_or_else(|| "origin".to_string()); let resolved_ref = ref_name.unwrap_or_else(|| "main".to_string()); // Parent path (where we keep the cloned parent) let parent_path_str = parent_dir(); // Branch: from_current, from_path, or git_url let mut cloned_from_local = false; let url = if from_current { // Detect git URL from current directory if !git::is_git_repository(config_dir) { return Err(PakkerError::Fork( "Not a git repository. Use --git-url or run 'git init' first." .to_string(), )); } git::get_remote_url(config_dir, &resolved_remote)? } else if let Some(fp) = from_path { // Use provided local path as source; infer upstream remote from it let path = Path::new(&fp); if !git::is_git_repository(path) { return Err(PakkerError::Fork(format!( "Provided path is not a git repository: {}", path.display() ))); } // Infer upstream remote URL from the existing local clone let upstream_url = git::get_primary_remote_url(path)?; // Reject file:// or non-network remotes validate_git_url(&upstream_url)?; // Ensure working tree is clean let vcs_type = git::detect_vcs_type(path); if git::repo_has_uncommitted_changes(path)? { let error_msg = match vcs_type { VcsType::Git => { "Local repository at --from-path has uncommitted changes. Commit or \ stash them before proceeding." }, VcsType::Jujutsu => { "Local repository at --from-path has uncommitted changes. Run 'jj \ commit' to save changes before proceeding." }, VcsType::None => { "Local repository at --from-path has uncommitted changes. Please \ clean the directory before proceeding." }, }; return Err(PakkerError::Fork(error_msg.to_string())); } // VCS-specific validation match vcs_type { VcsType::Git => { // Attempt lightweight fetch of remote refs to refresh remote tracking match git::fetch_remote_light(path, &resolved_remote, &resolved_ref) { Ok(()) => println!("Fetched remote refs for verification"), Err(e) => { log::warn!("Lightweight fetch from upstream failed: {e}"); println!( "Warning: could not perform lightweight fetch from upstream. \ Proceeding with local clone; subsequent sync may require \ network." ); }, } // Compare local ref vs remote ref let remote_ref = format!("{resolved_remote}/{resolved_ref}"); match git::ahead_behind(path, &resolved_ref, &remote_ref) { Ok((ahead, _behind)) => { if ahead > 0 { return Err(PakkerError::Fork(format!( "Local repository at {} has {} commits not present on \ upstream {}. Push or use --git-url if you intend to use an \ upstream that contains these commits.", path.display(), ahead, upstream_url ))); } }, Err(e) => { log::warn!("Could not compute ahead/behind: {e}"); }, } }, VcsType::Jujutsu => { // For jujutsu, we skip git-specific remote validation since jj has // different synchronization patterns println!( "Warning: Skipping remote validation for jujutsu repository. Ensure \ your jj repo is in sync with remote before proceeding." ); // Check if there are any changes that haven't been pushed to the remote if let Ok(output) = std::process::Command::new("jj") .args(["log", "--limit", "1", "--template", ""]) .current_dir(path) .output() && !output.stdout.is_empty() { println!( "Note: Jujutsu repository detected. Make sure to run 'jj git \ push' to sync changes with remote if needed." ); } }, VcsType::None => { // No VCS-specific validation needed }, } // Compute parent lock/config hashes for reproducibility let parent_lock_path = if path.join("pakker-lock.json").exists() { path.join("pakker-lock.json") } else { path.join("pakku-lock.json") }; if parent_lock_path.exists() { let lock_content = fs::read_to_string(&parent_lock_path).map_err(|e| { PakkerError::Fork(format!("Failed to read parent lock file: {e}")) })?; let lock_hash = hash_content(&lock_content); local_config.parent_lock_hash = Some(lock_hash); } let parent_config_path = if path.join("pakker.json").exists() { path.join("pakker.json") } else { path.join("pakku.json") }; if parent_config_path.exists() { let config_content = fs::read_to_string(&parent_config_path).map_err(|e| { PakkerError::Fork(format!("Failed to read parent config: {e}")) })?; let config_hash = hash_content(&config_content); local_config.parent_config_hash = Some(config_hash); } // Now clone from the local path into .pakku/parent — this avoids // re-downloading objects let parent_path = Path::new(&parent_path_str); println!( "Cloning parent repository from local path {}...", path.display() ); git::clone_repository(&fp, parent_path, &resolved_ref, None)?; // Ensure the cloned repo's origin is set to the upstream URL (not the local // path) git::set_remote_url(parent_path, &resolved_remote, &upstream_url)?; // Mark that we've already cloned from local cloned_from_local = true; // We will persist upstream_url as the canonical parent id upstream_url } else if let Some(url) = git_url { url } else { return Err(PakkerError::Fork( "Either --git-url, --from-current or --from-path must be specified" .to_string(), )); }; let parent_path = Path::new(&parent_path_str); // If we did not already clone from local, perform network clone and checks if cloned_from_local { println!( "Parent repository was cloned from local path; skipping network clone." ); } else { // Check if parent directory already exists and is not empty if parent_path.exists() { let is_empty = parent_path .read_dir() .map(|mut entries| entries.next().is_none()) .unwrap_or(false); if !is_empty { return Err(PakkerError::Fork(format!( "Directory not empty: {}", parent_path.display() ))); } } println!("Cloning parent repository..."); println!(" URL: {url}"); println!(" Ref: {resolved_ref}"); git::clone_repository(&url, parent_path, &resolved_ref, None)?; } let commit_sha = git::get_commit_sha(parent_path, &resolved_ref)?; // Detect ref type if not specified let resolved_ref_type = if let Some(rt) = ref_type { rt } else { git::resolve_ref_type(parent_path, &resolved_ref)? }; let parent_config = ParentConfig { type_: "git".to_string(), id: url.clone(), version: Some(commit_sha[..8].to_string()), ref_: resolved_ref.clone(), ref_type: resolved_ref_type, remote_name: resolved_remote, }; local_config.parent = Some(parent_config); local_config.save(config_dir)?; // Add .pakku/parent to .gitignore add_to_gitignore()?; println!(); println!("✓ Fork initialized successfully"); println!(" Parent: {url}"); println!(" Ref: {} ({})", resolved_ref, match resolved_ref_type { RefType::Branch => "branch", RefType::Tag => "tag", RefType::Commit => "commit", }); println!(" Commit: {}", &commit_sha[..8]); println!(); println!("Run 'pakku fork sync' to sync with parent."); Ok(()) } fn execute_set( git_url: Option, ref_name: String, ref_type: Option, remote: Option, ) -> Result<(), PakkerError> { let config_dir = Path::new("."); let mut local_config = LocalConfig::load(config_dir)?; if local_config.parent.is_none() { return Err(PakkerError::Fork( "No parent configured. Run 'pakku fork init' first.".to_string(), )); } let mut parent = local_config.parent.unwrap(); if let Some(url) = git_url { validate_git_url(&url)?; parent.id = url; } parent.ref_ = ref_name; if let Some(rt) = ref_type { parent.ref_type = rt; } if let Some(remote_name) = remote { parent.remote_name = remote_name; } local_config.parent = Some(parent.clone()); local_config.save(config_dir)?; println!("✓ Fork configuration updated"); println!(" Parent: {}", parent.id); println!(" Ref: {} ({})", parent.ref_, match parent.ref_type { RefType::Branch => "branch", RefType::Tag => "tag", RefType::Commit => "commit", }); println!(); println!("Run 'pakku fork sync' to sync with new configuration."); Ok(()) } fn execute_show() -> Result<(), PakkerError> { let config_dir = Path::new("."); let local_config = LocalConfig::load(config_dir)?; if let Some(parent) = local_config.parent { println!("Fork Configuration:"); println!(" Parent URL: {}", parent.id); println!(" Type: {}", match parent.ref_type { RefType::Branch => "branch", RefType::Tag => "tag", RefType::Commit => "commit", }); println!(" Ref: {}", parent.ref_); println!(" Remote: {}", parent.remote_name); if let Some(version) = parent.version { println!(" Last synced commit: {version}"); } else { println!(" Last synced commit: never synced"); } if !local_config.projects.is_empty() { println!(); println!("Project Overrides ({}):", local_config.projects.len()); for (slug, proj_config) in &local_config.projects { print!(" - {slug}"); let mut details = Vec::new(); if let Some(version) = &proj_config.version { details.push(format!("version={version}")); } if let Some(side) = &proj_config.side { details.push(format!("side={side}")); } if let Some(strategy) = &proj_config.update_strategy { details.push(format!("updateStrategy={strategy}")); } if !details.is_empty() { print!(" ({})", details.join(", ")); } println!(); } } } else { println!("No fork configured."); println!("Run 'pakku fork init' to initialize a fork."); } Ok(()) } fn execute_unset() -> Result<(), PakkerError> { let config_dir = Path::new("."); let mut local_config = LocalConfig::load(config_dir)?; if local_config.parent.is_none() { println!("No fork configured."); return Ok(()); } // Prompt for confirmation print!("Are you sure you want to remove fork configuration? [y/N] "); std::io::stdout().flush().unwrap(); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); if !input.trim().eq_ignore_ascii_case("y") { println!("Cancelled."); return Ok(()); } // Remove parent directory let parent_path_str = parent_dir(); let parent_path = Path::new(&parent_path_str); if parent_path.exists() { fs::remove_dir_all(parent_path).map_err(|e| { PakkerError::Fork(format!("Failed to remove parent directory: {e}")) })?; } // Clear parent configuration local_config.parent = None; local_config.parent_lock_hash = None; local_config.parent_config_hash = None; local_config.save(config_dir)?; println!("✓ Fork configuration removed"); Ok(()) } fn execute_sync() -> Result<(), PakkerError> { let config_dir = Path::new("."); let mut local_config = LocalConfig::load(config_dir)?; let parent = local_config.parent.as_ref().ok_or_else(|| { PakkerError::Fork( "No parent configured. Run 'pakku fork init' first.".to_string(), ) })?; let parent_path_str = parent_dir(); let parent_path = Path::new(&parent_path_str); if parent_path.exists() { println!("Fetching parent updates..."); git::fetch_updates(parent_path, &parent.remote_name, &parent.ref_, None)?; git::reset_to_ref(parent_path, &parent.remote_name, &parent.ref_)?; } else { println!("Parent repository not found. Cloning..."); git::clone_repository(&parent.id, parent_path, &parent.ref_, None)?; } let commit_sha = git::get_commit_sha(parent_path, &parent.ref_)?; let mut integrity = None; // Try pakker files first, fall back to pakku files let parent_lock_path = if parent_path.join("pakker-lock.json").exists() { parent_path.join("pakker-lock.json") } else { parent_path.join("pakku-lock.json") }; let parent_config_path = if parent_path.join("pakker.json").exists() { parent_path.join("pakker.json") } else { parent_path.join("pakku.json") }; if parent_lock_path.exists() { let lock_content = fs::read_to_string(&parent_lock_path).map_err(|e| { PakkerError::Fork(format!("Failed to read parent lock file: {e}")) })?; let lock_hash = hash_content(&lock_content); if let Some(prev_hash) = &local_config.parent_lock_hash && prev_hash != &lock_hash { log::warn!("Parent lock file has changed since last sync"); log::warn!(" Previous hash: {prev_hash}"); log::warn!(" Current hash: {lock_hash}"); } local_config.parent_lock_hash = Some(lock_hash); let config_content = if parent_config_path.exists() { fs::read_to_string(&parent_config_path).map_err(|e| { PakkerError::Fork(format!("Failed to read parent config: {e}")) })? } else { String::new() }; let config_hash = hash_content(&config_content); if let Some(prev_hash) = &local_config.parent_config_hash && prev_hash != &config_hash { log::warn!("Parent config file has changed since last sync"); log::warn!(" Previous hash: {prev_hash}"); log::warn!(" Current hash: {config_hash}"); } local_config.parent_config_hash = Some(config_hash); integrity = Some(ForkIntegrity::new( local_config.parent_lock_hash.clone().unwrap_or_default(), commit_sha.clone(), local_config.parent_config_hash.clone().unwrap_or_default(), )); } if let Some(ref integrity_data) = integrity { log::info!( "Parent integrity verified at timestamp {}", integrity_data.verified_at ); } if let Some(parent) = local_config.parent.as_mut() { parent.version = Some(commit_sha[..8].to_string()); } local_config.save(config_dir)?; println!(); println!("✓ Parent sync complete"); println!(" Commit: {}", &commit_sha[..8]); println!(); println!("Run 'pakku export' to merge changes from parent."); Ok(()) } fn execute_promote(projects: Vec) -> Result<(), PakkerError> { let config_dir = Path::new("."); let local_config = LocalConfig::load(config_dir)?; if local_config.parent.is_none() { return Err(PakkerError::Fork( "No parent configured. Run 'pakku fork init' first.".to_string(), )); } if projects.is_empty() { return Err(PakkerError::Fork( "No projects specified. Usage: pakku fork promote ..." .to_string(), )); } // Load current config let config = Config::load(config_dir)?; // Verify all projects exist for project_arg in &projects { let found = config .projects .as_ref() .and_then(|projs| projs.get(project_arg)) .is_some(); if !found { return Err(PakkerError::Fork(format!( "Project not found: {project_arg}" ))); } } println!("Note: In the current architecture, projects in pakku.json are"); println!("automatically merged with parent projects during export."); println!(); println!("The following projects are already in pakku.json:"); for project in &projects { println!(" - {project}"); } println!(); println!("These will be included in exports automatically."); Ok(()) } fn add_to_gitignore() -> Result<(), PakkerError> { let gitignore_path = Path::new(".gitignore"); let parent_dir = parent_dir(); // Check if .gitignore exists and already contains the entry if gitignore_path.exists() { let content = fs::read_to_string(gitignore_path).map_err(|e| { PakkerError::Fork(format!("Failed to read .gitignore: {e}")) })?; if content.lines().any(|line| line.trim() == parent_dir) { return Ok(()); } } // Append to .gitignore let mut file = fs::OpenOptions::new() .create(true) .append(true) .open(gitignore_path) .map_err(|e| { PakkerError::Fork(format!("Failed to open .gitignore: {e}")) })?; writeln!(file, "{parent_dir}").map_err(|e| { PakkerError::Fork(format!("Failed to write to .gitignore: {e}")) })?; Ok(()) }