use std::path::Path;
use miette::{Context, IntoDiagnostic, Result};
use panproto_core::vcs::{self, Store as _};
use super::helpers::open_repo;
use super::migrate::migrate_data_between_schemas;
#[allow(clippy::struct_excessive_bools)]
pub struct BranchCmdOptions<'a> {
pub name: Option<&'a str>,
pub delete: bool,
pub force_delete: bool,
pub force: bool,
pub rename: Option<&'a str>,
pub verbose: bool,
#[allow(dead_code)]
pub all: bool,
}
pub fn cmd_branch(opts: &BranchCmdOptions<'_>) -> Result<()> {
let BranchCmdOptions {
name,
delete,
force_delete,
force,
rename,
verbose,
all: _,
} = *opts;
let mut repo = open_repo()?;
if let Some(new_name) = rename {
let old_name = name.ok_or_else(|| miette::miette!("branch name required for rename"))?;
vcs::refs::rename_branch(repo.store_mut(), old_name, new_name).into_diagnostic()?;
println!("Renamed branch {old_name} -> {new_name}");
return Ok(());
}
if force_delete {
let branch_name = name.ok_or_else(|| miette::miette!("branch name required for -D"))?;
vcs::refs::force_delete_branch(repo.store_mut(), branch_name).into_diagnostic()?;
println!("Deleted branch {branch_name} (force)");
return Ok(());
}
if delete {
let branch_name = name.ok_or_else(|| miette::miette!("branch name required for delete"))?;
if force {
vcs::refs::force_delete_branch(repo.store_mut(), branch_name).into_diagnostic()?;
println!("Deleted branch {branch_name} (force)");
} else {
vcs::refs::delete_branch(repo.store_mut(), branch_name).into_diagnostic()?;
println!("Deleted branch {branch_name}");
}
return Ok(());
}
if let Some(name) = name {
let head_id = vcs::store::resolve_head(repo.store())
.into_diagnostic()?
.ok_or_else(|| miette::miette!("no commits yet"))?;
vcs::refs::create_branch(repo.store_mut(), name, head_id).into_diagnostic()?;
println!("Created branch {name} at {}", head_id.short());
} else {
let branches = vcs::refs::list_branches(repo.store()).into_diagnostic()?;
let current = match repo.store().get_head().into_diagnostic()? {
vcs::HeadState::Branch(name) => Some(name),
vcs::HeadState::Detached(_) => None,
};
for (branch_name, id) in &branches {
let marker = if current.as_deref() == Some(branch_name) {
"* "
} else {
" "
};
if verbose {
let obj = repo.store().get(id).into_diagnostic()?;
if let vcs::Object::Commit(c) = obj {
println!("{marker}{branch_name} {} {}", id.short(), c.message);
} else {
println!("{marker}{branch_name} {}", id.short());
}
} else {
println!("{marker}{branch_name} {}", id.short());
}
}
}
Ok(())
}
#[allow(clippy::struct_excessive_bools)]
pub struct TagCmdOptions<'a> {
pub name: Option<&'a str>,
pub delete: bool,
pub annotate: bool,
pub message: Option<&'a str>,
pub list: bool,
pub force: bool,
}
pub fn cmd_tag(opts: &TagCmdOptions<'_>) -> Result<()> {
let TagCmdOptions {
name,
delete,
annotate,
message,
list,
force,
} = *opts;
let mut repo = open_repo()?;
if list || (name.is_none() && !delete) {
let tags = vcs::refs::list_tags(repo.store()).into_diagnostic()?;
for (tag_name, id) in &tags {
println!("{tag_name} {}", id.short());
}
return Ok(());
}
let tag_name = name.ok_or_else(|| miette::miette!("tag name required"))?;
if delete {
vcs::refs::delete_tag(repo.store_mut(), tag_name).into_diagnostic()?;
println!("Deleted tag {tag_name}");
return Ok(());
}
let head_id = vcs::store::resolve_head(repo.store())
.into_diagnostic()?
.ok_or_else(|| miette::miette!("no commits yet"))?;
if annotate || message.is_some() {
let msg = message.unwrap_or("");
vcs::refs::create_annotated_tag(repo.store_mut(), tag_name, head_id, "anonymous", msg)
.into_diagnostic()?;
println!("Tagged {} as {tag_name} (annotated)", head_id.short());
} else if force {
vcs::refs::create_tag_force(repo.store_mut(), tag_name, head_id).into_diagnostic()?;
println!("Tagged {} as {tag_name} (force)", head_id.short());
} else {
vcs::refs::create_tag(repo.store_mut(), tag_name, head_id).into_diagnostic()?;
println!("Tagged {} as {tag_name}", head_id.short());
}
Ok(())
}
pub fn cmd_checkout(
target: &str,
create: bool,
detach: bool,
migrate_dir: Option<&Path>,
) -> Result<()> {
let mut repo = open_repo()?;
let pre_checkout_schema_id = if migrate_dir.is_some() {
vcs::store::resolve_head(repo.store())
.into_diagnostic()?
.and_then(|head_id| {
let obj = repo.store().get(&head_id).ok()?;
if let vcs::Object::Commit(c) = obj {
Some(c.schema_id)
} else {
None
}
})
} else {
None
};
if create {
let head_id = vcs::store::resolve_head(repo.store())
.into_diagnostic()?
.ok_or_else(|| miette::miette!("no commits yet"))?;
vcs::refs::create_and_checkout_branch(repo.store_mut(), target, head_id)
.into_diagnostic()?;
println!("Switched to a new branch '{target}'");
} else if detach {
let id = vcs::refs::resolve_ref(repo.store(), target)
.into_diagnostic()
.wrap_err_with(|| format!("cannot resolve '{target}'"))?;
vcs::refs::checkout_detached(repo.store_mut(), id).into_diagnostic()?;
println!("HEAD is now at {}", id.short());
} else {
let branch_ref = format!("refs/heads/{target}");
if repo
.store()
.get_ref(&branch_ref)
.into_diagnostic()?
.is_some()
{
vcs::refs::checkout_branch(repo.store_mut(), target).into_diagnostic()?;
println!("Switched to branch '{target}'");
} else {
let id = vcs::refs::resolve_ref(repo.store(), target)
.into_diagnostic()
.wrap_err_with(|| format!("cannot resolve '{target}'"))?;
vcs::refs::checkout_detached(repo.store_mut(), id).into_diagnostic()?;
println!("HEAD is now at {}", id.short());
}
}
if let Some(data_dir) = migrate_dir {
maybe_migrate_data(repo.store(), pre_checkout_schema_id, data_dir)?;
}
Ok(())
}
#[allow(clippy::struct_excessive_bools)]
pub struct MergeCmdOptions<'a> {
pub branch: Option<&'a str>,
pub author: &'a str,
pub no_commit: bool,
pub ff_only: bool,
pub no_ff: bool,
pub squash: bool,
pub abort: bool,
pub message: Option<&'a str>,
pub verbose: bool,
}
pub fn cmd_merge(cmd_opts: &MergeCmdOptions<'_>, migrate_dir: Option<&Path>) -> Result<()> {
let MergeCmdOptions {
branch,
author,
no_commit,
ff_only,
no_ff,
squash,
abort,
message,
verbose: _verbose,
} = *cmd_opts;
if abort {
let repo = open_repo()?;
let merge_head = repo.store().root().join("MERGE_HEAD");
if merge_head.exists() {
std::fs::remove_file(&merge_head).into_diagnostic()?;
}
println!("Merge aborted.");
return Ok(());
}
let branch_name = branch.ok_or_else(|| miette::miette!("branch name required for merge"))?;
let mut repo = open_repo()?;
let pre_merge_schema_id = if migrate_dir.is_some() {
vcs::store::resolve_head(repo.store())
.into_diagnostic()?
.and_then(|head_id| {
let obj = repo.store().get(&head_id).ok()?;
if let vcs::Object::Commit(c) = obj {
Some(c.schema_id)
} else {
None
}
})
} else {
None
};
let opts = vcs::merge::MergeOptions {
no_commit,
ff_only,
no_ff,
squash,
message: message.map(ToOwned::to_owned),
};
let result = repo
.merge_with_options(branch_name, author, &opts)
.into_diagnostic()?;
if result.conflicts.is_empty() {
println!("Merge successful.");
println!(
"Merged schema has {} vertices, {} edges.",
result.merged_schema.vertex_count(),
result.merged_schema.edge_count()
);
} else {
println!("Merge produced {} conflict(s):", result.conflicts.len());
for conflict in &result.conflicts {
println!(" {conflict:?}");
}
miette::bail!("merge failed with {} conflict(s)", result.conflicts.len());
}
if cmd_opts.verbose {
if let Some(ref overlap) = result.pullback_overlap {
println!("\nPullback overlap detection:");
if overlap.shared_vertices.is_empty() {
println!(" No shared vertices detected.");
} else {
println!(" {} shared vertex(es):", overlap.shared_vertices.len());
let mut sorted: Vec<_> = overlap.shared_vertices.iter().collect();
sorted.sort();
for v in sorted {
println!(" {v}");
}
}
if !overlap.shared_edges.is_empty() {
println!(" {} shared edge(s):", overlap.shared_edges.len());
let mut sorted: Vec<_> = overlap.shared_edges.iter().collect();
sorted.sort();
for (src, tgt) in sorted {
println!(" {src} -> {tgt}");
}
}
}
}
if let Some(data_dir) = migrate_dir {
maybe_migrate_data(repo.store(), pre_merge_schema_id, data_dir)?;
}
Ok(())
}
pub fn maybe_migrate_data(
store: &dyn vcs::Store,
old_schema_id: Option<vcs::ObjectId>,
data_dir: &Path,
) -> Result<()> {
let Some(old_id) = old_schema_id else {
return Ok(());
};
let new_head_id = vcs::store::resolve_head(store)
.into_diagnostic()?
.ok_or_else(|| miette::miette!("no HEAD after operation"))?;
let new_obj = store.get(&new_head_id).into_diagnostic()?;
let vcs::Object::Commit(new_commit) = new_obj else {
return Ok(());
};
if old_id == new_commit.schema_id {
println!("Schemas are identical — no data migration needed.");
} else {
migrate_data_between_schemas(
store,
old_id,
new_commit.schema_id,
&new_commit.protocol,
data_dir,
)?;
}
Ok(())
}