initial commit

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
This commit is contained in:
raf 2026-01-29 19:36:25 +03:00
commit ef28bdaeb4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
63 changed files with 17292 additions and 0 deletions

677
src/cli/commands/fork.rs Normal file
View file

@ -0,0 +1,677 @@
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<String>,
from_current: bool,
from_path: Option<String>,
ref_name: Option<String>,
ref_type: Option<RefType>,
remote: Option<String>,
) -> 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()
{
if !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<String>,
ref_name: String,
ref_type: Option<RefType>,
remote: Option<String>,
) -> 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<String>) -> 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 <project>..."
.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(())
}