use anyhow::{anyhow, bail, Context, Error, Result};
use clap::Parser;
use pyo3::prelude::*;
use scopeguard::defer;
use semver::VersionReq;
use std::fs::{copy, create_dir, create_dir_all, remove_dir_all, remove_file, write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use super::manifest::Dependency;
use super::{Distributable, Id, Package, Registry, PATCHES_DIR_NAME};
use crate::rom::Rom;
const MERLON_DIR_NAME: &str = ".merlon";
const DEPENDENCIES_DIR_NAME: &str = ".merlon/dependencies";
const SUBREPO_DIR_NAME: &str = "papermario";
const VSCODE_DIR_NAME: &str = ".vscode";
const GITIGNORE_FILE_NAME: &str = ".gitignore";
#[derive(Debug)]
#[pyclass(module = "merlon.package.init")]
pub struct InitialisedPackage {
registry: Registry,
package_id: Id,
}
#[derive(Parser, Debug, Clone)]
#[pyclass(module = "merlon.package.init")]
pub struct InitialiseOptions {
#[arg(long)]
#[pyo3(get, set)]
pub baserom: PathBuf,
#[arg(long)]
#[pyo3(get, set)]
pub rev: Option<String>,
}
#[derive(Parser, Debug, Clone, Default)]
#[pyclass(module = "merlon.package.init")]
pub struct BuildRomOptions {
#[arg(long)]
#[pyo3(get, set)]
pub skip_configure: bool,
#[arg(short, long)]
#[pyo3(get, set)]
pub output: Option<PathBuf>,
#[arg(long)]
#[pyo3(get, set)]
pub clean: bool,
}
#[derive(Parser, Debug, Clone)]
#[pyclass(module = "merlon.package.init")]
pub struct AddDependencyOptions {
#[arg(long)]
#[pyo3(get, set)]
pub path: PathBuf,
}
#[pymethods]
impl Package {
pub fn to_initialised(
&self,
initialise_options: InitialiseOptions,
) -> Result<InitialisedPackage> {
if InitialisedPackage::is_initialised(self)? {
InitialisedPackage::from_initialised(self.clone())
} else {
InitialisedPackage::initialise(self.clone(), initialise_options)
}
}
}
#[pymethods]
impl InitialisedPackage {
#[staticmethod]
pub fn from_initialised(package: Package) -> Result<Self> {
if !Self::is_initialised(&package)? {
bail!("package is not initialised");
}
let dependencies_dir_path = package.path().join(DEPENDENCIES_DIR_NAME);
let mut registry = Registry::new();
let package_id = registry.register(package)?;
if dependencies_dir_path.is_dir() {
for path in dependencies_dir_path.read_dir()? {
let path = path?;
if path.file_type()?.is_dir() {
let package = Package::try_from(path.path())?;
registry.register(package)?;
}
}
}
Ok(Self {
registry,
package_id,
})
}
#[getter]
fn get_package(&self) -> Package {
self.package().clone()
}
pub fn package_id(&self) -> Id {
self.package_id
}
pub fn baserom_path(&self) -> PathBuf {
self.subrepo_path().join("ver/us/baserom.z64")
}
pub fn subrepo_path(&self) -> PathBuf {
self.package().path().join(SUBREPO_DIR_NAME)
}
#[pyo3(name = "registry")]
fn py_registry(&self) -> Registry {
self.registry().clone()
}
pub fn set_registry(&mut self, registry: Registry) {
self.registry = registry;
}
#[staticmethod]
pub fn initialise(package: Package, options: InitialiseOptions) -> Result<Self> {
if Self::is_initialised(&package)? {
bail!("package is already initialised, delete .merlon directory and try again to force reinitialisation");
}
if package.path().join(SUBREPO_DIR_NAME).exists() {
bail!(
"there is already a decomp clone here - delete the {} directory and try again",
SUBREPO_DIR_NAME
);
}
let manifest = package.manifest()?;
let rev = match &options.rev {
Some(rev) => Some(rev.as_str()),
None => manifest.get_direct_decomp_dependency_rev(),
};
let path_clone = package.path().to_owned();
let error_context = format!("failed to initialise package {}", &package);
let do_it = || {
let package_id_string = package.id()?.to_string();
let mut command = Command::new("git");
command.arg("clone");
if rev.is_none() {
command.arg("--depth=1");
}
let status = command
.arg("https://github.com/pmret/papermario.git")
.arg(SUBREPO_DIR_NAME)
.current_dir(package.path())
.status()?;
if !status.success() {
bail!("failed to clone decomp repository");
}
if let Some(rev) = &rev {
let status = Command::new("git")
.arg("reset")
.arg("--hard")
.arg(rev)
.current_dir(package.path().join(SUBREPO_DIR_NAME))
.status()?;
if !status.success() {
bail!("failed to checkout revision");
}
}
create_dir_all(
package
.path()
.join(SUBREPO_DIR_NAME)
.join("assets")
.join(&package_id_string),
)
.context("failed to create assets subdirectory")?;
if !options.baserom.is_file() {
bail!("baserom {:?} is not a file", options.baserom);
}
let baserom_path = package
.path()
.join(SUBREPO_DIR_NAME)
.join("ver/us/baserom.z64");
copy(options.baserom, &baserom_path)
.with_context(|| format!("failed to copy baserom to {:?}", baserom_path))?;
create_dir(package.path().join(MERLON_DIR_NAME))
.with_context(|| format!("failed to create {MERLON_DIR_NAME} directory"))?;
let vscode_dir = package.path().join(VSCODE_DIR_NAME);
create_dir(&vscode_dir)
.with_context(|| format!("failed to create {} directory", vscode_dir.display()))?;
write(
vscode_dir.join("c_cpp_properties.json"),
include_str!("../../templates/.vscode/c_cpp_properties.json"),
)
.with_context(|| format!("failed to create {VSCODE_DIR_NAME}/c_cpp_properties.json"))?;
write(
vscode_dir.join("extensions.json"),
include_str!("../../templates/.vscode/extensions.json"),
)
.with_context(|| format!("failed to create {VSCODE_DIR_NAME}/extensions.json"))?;
write(
vscode_dir.join("settings.json"),
include_str!("../../templates/.vscode/settings.json"),
)
.with_context(|| format!("failed to create {VSCODE_DIR_NAME}/settings.json"))?;
write(
vscode_dir.join("tasks.json"),
include_str!("../../templates/.vscode/tasks.json"),
)
.with_context(|| format!("failed to create {VSCODE_DIR_NAME}/tasks.json"))?;
let gitignore_path = package.path().join(GITIGNORE_FILE_NAME);
if !gitignore_path.exists() {
write(gitignore_path, include_str!("../../templates/gitignore"))
.with_context(|| format!("failed to create {GITIGNORE_FILE_NAME}"))?;
}
if package
.path()
.join(SUBREPO_DIR_NAME)
.join("install.sh")
.is_file() {
let status = Command::new("bash")
.arg("install.sh")
.current_dir(package.path().join(SUBREPO_DIR_NAME))
.status()?;
if !status.success() {
bail!("failed to run decomp install.sh");
}
} else {
let status = Command::new("bash")
.arg("install_deps.sh")
.current_dir(package.path().join(SUBREPO_DIR_NAME))
.status()?;
if !status.success() {
bail!("failed to run decomp install_deps.sh");
}
let status = Command::new("bash")
.arg("install_compilers.sh")
.current_dir(package.path().join(SUBREPO_DIR_NAME))
.status()?;
if !status.success() {
bail!("failed to run decomp install_compilers.sh");
}
}
let initialised = Self::from_initialised(package)?;
let main_head = initialised.git_head_commit()?;
initialised
.package()
.edit_manifest(|manifest| manifest.upsert_decomp_dependency(main_head))?;
let branch_name = initialised.package_id().to_string();
initialised.git_create_branch(&branch_name)?;
initialised.git_checkout_branch(&branch_name)?;
initialised
.package()
.apply_patches_to_decomp_repo(&initialised.subrepo_path())?;
initialised.setup_git_branches()?;
Ok(initialised)
};
match do_it() {
Err(e) => {
let _ = remove_dir_all(path_clone.join(SUBREPO_DIR_NAME));
let _ = remove_dir_all(path_clone.join(MERLON_DIR_NAME));
let _ = remove_dir_all(path_clone.join(VSCODE_DIR_NAME));
let _ = remove_file(path_clone.join(GITIGNORE_FILE_NAME));
Err(e).context(error_context)
}
result => result,
}
}
#[staticmethod]
pub fn is_initialised(package: &Package) -> Result<bool> {
let path = package.path();
if !path.join(SUBREPO_DIR_NAME).is_dir() {
return Ok(false);
}
let status = Command::new("git")
.arg("status")
.current_dir(path.join(SUBREPO_DIR_NAME))
.stdout(Stdio::null())
.status()?;
if !status.success() {
return Ok(false);
}
if !path.join(MERLON_DIR_NAME).is_dir() {
return Ok(false);
}
Ok(true)
}
pub fn setup_git_branches(&self) -> Result<()> {
let package_id_string = self.package_id.to_string();
if self.git_branch_exists(&package_id_string)? {
log::info!("updating patches directory");
self.update_patches_dir()
.context("failed to update patches dir for backup")?;
}
log::info!("starting repo sync");
self.git_checkout_branch("main")?;
let dependencies_including_self = {
let mut deps = self.registry.get_dependencies(self.package_id)?;
let manifest = self.package().manifest()?;
let version = manifest.metadata().version();
deps.insert(Dependency::Package {
id: self.package_id,
version: VersionReq {
comparators: vec![semver::Comparator {
op: semver::Op::Exact,
major: version.major,
minor: Some(version.minor),
patch: Some(version.patch),
pre: version.pre.clone(),
}],
},
});
deps
};
for dependency in dependencies_including_self {
if let Dependency::Package { id, .. } = dependency {
let id_string = id.to_string();
if self.git_branch_exists(&id_string)? {
self.git_delete_branch(&id_string)?;
}
}
}
let patch_order = self.registry.calc_dependency_patch_order(self.package_id)?;
let repo = self.subrepo_path();
for id in patch_order {
let package = self.registry.get_or_error(id)?;
log::info!("applying patches of package: {}", &package);
let id_string = id.to_string();
self.git_create_branch(&id_string)?;
self.git_checkout_branch(&id_string)?;
let package = self.registry.get_or_error(id)?;
package.apply_patches_to_decomp_repo(&repo)?;
}
if self.git_current_branch()? != self.package_id.to_string() {
bail!("patch order was incorrect");
}
Ok(())
}
pub fn is_git_dirty(&self) -> Result<bool> {
let output = Command::new("git")
.arg("status")
.arg("--porcelain")
.current_dir(self.subrepo_path())
.output()?;
if !output.status.success() {
bail!("failed to run git status");
}
Ok(!output.stdout.is_empty())
}
pub fn build_rom(&self, options: BuildRomOptions) -> Result<Rom> {
let dir = self.subrepo_path();
if !options.skip_configure {
let mut command = Command::new("./configure");
command
.arg("--shift")
.arg("us");
if options.clean {
command.arg("--clean");
}
let status = command.current_dir(&dir).status()?;
if !status.success() {
bail!("failed to configure");
}
}
let status = Command::new("ninja").current_dir(&dir).status()?;
if !status.success() {
bail!("failed to build");
}
let rom = dir.join("ver/us/build/papermario.z64");
if let Some(output) = options.output {
std::fs::copy(rom, &output)?;
Ok(output.into())
} else {
Ok(rom.into())
}
}
pub fn update_patches_dir(&self) -> Result<()> {
let package_id_str = self.package_id.to_string();
if self.git_current_branch()? != package_id_str {
bail!("repo is not on package branch {}", package_id_str);
}
if self.is_git_dirty()? {
bail!("repo is dirty, commit changes and try again");
}
let dir = self.package().path().join(PATCHES_DIR_NAME);
remove_dir_all(&dir)
.with_context(|| format!("failed to remove patches dir {}", dir.display()))?;
create_dir(&dir)
.with_context(|| format!("failed to create patches dir {}", dir.display()))?;
let branch_order = std::iter::once("main".to_string()).chain(
self.registry()
.calc_dependency_patch_order(self.package_id)?
.into_iter()
.map(|id| id.to_string()),
);
let mut diff_against = None;
for branch in branch_order.rev() {
if branch != package_id_str && self.git_branch_exists(&branch)? {
diff_against = Some(branch);
break;
}
}
let diff_against = diff_against.ok_or_else(|| anyhow!("no branch to diff against"))?;
let diff_against_package_name = match diff_against.as_str() {
"main" => "Paper Mario (N64) decompilation".to_string(),
_ => {
let package = self.registry.get_or_error(diff_against.parse()?)?;
format!("{}", package)
}
};
log::info!(
"saving patches since dependency: {}",
&diff_against_package_name
);
let status = Command::new("git")
.arg("format-patch")
.arg(format!("{}..HEAD", diff_against))
.arg("-o")
.arg(&dir.canonicalize()?)
.arg("--minimal")
.arg("--binary")
.arg("--ignore-cr-at-eol")
.arg("--function-context") .arg("--keep-subject")
.arg("--no-merges")
.arg("--no-stdout")
.arg("--")
.arg("src")
.arg("include")
.arg("assets") .arg("ver/us")
.arg("--no-track") .current_dir(self.subrepo_path())
.status()?;
if !status.success() {
bail!("failed git format-patch");
}
let patches = std::fs::read_dir(&dir)?
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension()? == "patch" {
Some(path)
} else {
None
}
})
.collect::<Vec<_>>();
log::info!("saved {} patches", patches.len());
Ok(())
}
pub fn add_dependency(&mut self, options: AddDependencyOptions) -> Result<Id> {
let path = options.path;
let dependencies_dir = self.package().path().join(DEPENDENCIES_DIR_NAME);
create_dir_all(&dependencies_dir).with_context(|| {
format!(
"failed to create dependencies dir {}",
dependencies_dir.display()
)
})?;
let package = if super::is_unexported_package(&path) {
let package =
Package::try_from(path).context("failed to open dependency as package")?;
let path = dependencies_dir.join(package.id()?.to_string());
if path.is_dir() {
log::info!("dependency directory already exists, updating it");
remove_dir_all(&path)?;
}
let package = package
.clone_to_dir(path)
.context("failed to clone package to dependencies dir")?;
if let Ok(initialised) = InitialisedPackage::try_from(package.clone()) {
log::info!("copying dependencies of new dependency to this package");
for id in initialised.registry().package_ids() {
if !self.registry.has(id) {
self.add_dependency(AddDependencyOptions {
path: self.registry.get_or_error(id)?.path().to_owned(),
})?;
}
}
}
package
} else if super::distribute::is_distributable_package(&path) {
let distributable = Distributable::try_from(path)
.context("failed to open dependency as distributable")?;
let manifest = distributable
.manifest(self.baserom_path())
.context("failed to read dependency manifest")?;
let package_id = manifest.metadata().id().to_string();
let path = dependencies_dir.join(package_id);
if path.is_dir() {
log::info!("dependency directory already exists, updating it");
remove_dir_all(&path).context("failed to remove existing dependency directory")?;
}
distributable
.open_to_dir(super::distribute::OpenOptions {
output: Some(path),
baserom: self.baserom_path(),
})
.context("failed to open distributable to dependencies dir")?
} else {
bail!(
"not a package directory or distributable file: {}",
path.display()
);
};
log::info!("adding dependency: {}", package);
let id = package.id()?;
let id = match self.registry.has(id) {
true => id,
false => self.registry.register(package)?,
};
let dependency: Dependency = self
.registry
.get_or_error(id)
.context("dependency not added to registry correctly")?
.try_into()?;
self.package()
.edit_manifest(move |manifest| manifest.declare_direct_dependency(dependency))?;
Ok(id)
}
}
impl InitialisedPackage {
pub fn package(&self) -> &Package {
self.registry
.get(self.package_id)
.expect("package somehow removed from registry")
}
pub fn registry(&self) -> &Registry {
&self.registry
}
fn git_create_branch(&self, branch_name: &str) -> Result<()> {
let status = Command::new("git")
.arg("branch")
.arg(&branch_name)
.arg("--no-track") .current_dir(self.subrepo_path())
.status()?;
if !status.success() {
bail!("failed to create git branch {}", branch_name);
}
Ok(())
}
fn git_current_branch(&self) -> Result<String> {
let output = Command::new("git")
.arg("rev-parse")
.arg("--abbrev-ref")
.arg("HEAD")
.current_dir(self.subrepo_path())
.output()?;
if !output.status.success() {
panic!("failed to run git rev-parse");
}
String::from_utf8(output.stdout)
.map(|s| s.trim().to_string())
.map_err(Into::into)
}
fn git_checkout_branch(&self, branch_name: &str) -> Result<()> {
let status = Command::new("git")
.arg("checkout")
.arg(&branch_name)
.current_dir(self.subrepo_path())
.status()?;
if !status.success() {
bail!("failed to checkout git branch {}", branch_name);
}
Ok(())
}
fn git_stash(&self) -> Result<()> {
let status = Command::new("git")
.arg("stash")
.current_dir(self.subrepo_path())
.status()?;
if !status.success() {
bail!("failed to run git stash");
}
Ok(())
}
fn git_stash_pop(&self) -> Result<()> {
let status = Command::new("git")
.arg("stash")
.arg("pop")
.current_dir(self.subrepo_path())
.status()
.expect("failed to run git stash pop");
if !status.success() {
bail!("failed to run git stash pop");
}
Ok(())
}
fn git_branch_exists(&self, branch_name: &str) -> Result<bool> {
let output = Command::new("git")
.arg("branch")
.arg("--list")
.arg(&branch_name)
.current_dir(self.subrepo_path())
.output()?;
if !output.status.success() {
bail!("failed to run git branch --list {}", branch_name);
}
Ok(!output.stdout.is_empty())
}
fn git_delete_branch(&self, branch_name: &str) -> Result<()> {
let status = Command::new("git")
.arg("branch")
.arg("-D")
.arg(&branch_name)
.current_dir(self.subrepo_path())
.status()?;
if !status.success() {
bail!("failed to run git branch -D {}", branch_name);
}
Ok(())
}
pub fn update_decomp(&self) -> Result<()> {
let main_branch = "main";
let prev_branch = self.git_current_branch()?;
if self.is_git_dirty()? {
self.git_stash()?;
defer!(warn_if_err(self.git_stash_pop()));
}
if prev_branch != main_branch {
self.git_checkout_branch(main_branch)?;
}
let status = Command::new("git")
.arg("pull")
.current_dir(self.subrepo_path())
.status()?;
if !status.success() {
bail!("failed to run git pull");
}
if prev_branch != main_branch {
self.git_checkout_branch(&prev_branch)?;
let status = Command::new("git")
.arg("merge")
.arg(main_branch)
.current_dir(self.subrepo_path())
.status()?;
if !status.success() {
bail!("failed to run git merge");
}
}
let main_head = self.git_head_commit()?;
self.package()
.edit_manifest(|manifest| manifest.upsert_decomp_dependency(main_head))
}
fn git_head_commit(&self) -> Result<String> {
let output = Command::new("git")
.arg("rev-parse")
.arg("HEAD")
.current_dir(self.subrepo_path())
.output()?;
if !output.status.success() {
bail!("failed to run git rev-parse");
}
String::from_utf8(output.stdout)
.map(|s| s.trim().to_string())
.map_err(Into::into)
}
}
impl TryFrom<Package> for InitialisedPackage {
type Error = Error;
fn try_from(package: Package) -> Result<Self> {
Self::from_initialised(package)
}
}
fn warn_if_err<T, E: std::fmt::Debug>(result: Result<T, E>) {
if let Err(err) = result {
log::warn!("{:?}", err);
}
}