use crate::error::{Result, ToriiError};
use git2::{Repository, SubmoduleUpdateOptions};
use std::path::Path;
use std::process::Command;
#[derive(Debug, Default)]
pub struct AddOpts {
pub branch: Option<String>,
pub name: Option<String>,
pub recursive: bool,
}
pub fn add(repo_path: &Path, url: &str, path: &Path, opts: &AddOpts) -> Result<()> {
let mut repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let abs_target = repo.workdir()
.ok_or_else(|| ToriiError::InvalidConfig("repo has no working directory (bare)".into()))?
.join(path);
if abs_target.exists() {
return Err(ToriiError::InvalidConfig(format!(
"{} already exists. Submodule paths must be empty.",
abs_target.display()
)));
}
let sm_name = opts
.name
.clone()
.unwrap_or_else(|| path.to_string_lossy().to_string());
let workdir_oid = {
let mut sm = repo
.submodule(url, path, true)
.map_err(ToriiError::Git)?;
let mut clone_opts = SubmoduleUpdateOptions::new();
let _cloned = sm.clone(Some(&mut clone_opts)).map_err(ToriiError::Git)?;
sm.add_to_index(true).map_err(ToriiError::Git)?;
sm.add_finalize().map_err(ToriiError::Git)?;
sm.workdir_id()
};
if let Some(branch) = &opts.branch {
repo.submodule_set_branch(&sm_name, branch)
.map_err(ToriiError::Git)?;
}
println!(
"📦 Submodule added\n url: {}\n path: {}\n commit: {}",
url,
path.display(),
workdir_oid
.map(|o| o.to_string()[..7].to_string())
.unwrap_or_else(|| "?".to_string())
);
if let Some(branch) = &opts.branch {
println!(" branch: {branch}");
}
if opts.recursive {
let nested_root = repo
.workdir()
.ok_or_else(|| ToriiError::InvalidConfig("bare repo".into()))?
.join(path);
recurse_update(&nested_root, true)?;
}
println!("\n💡 Don't forget to commit: torii save -am \"add submodule {}\"", path.display());
Ok(())
}
fn recurse_update(repo_path: &Path, init_missing: bool) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let mut subs = repo.submodules().map_err(ToriiError::Git)?;
if subs.is_empty() {
return Ok(());
}
for sm in &mut subs {
let name = sm.name().unwrap_or("?").to_string();
let mut up_opts = SubmoduleUpdateOptions::new();
sm.update(init_missing, Some(&mut up_opts))
.map_err(|e| ToriiError::InvalidConfig(format!("recurse update {name}: {e}")))?;
let child_path = sm.path().to_path_buf();
let child_abs = repo
.workdir()
.ok_or_else(|| ToriiError::InvalidConfig("bare repo".into()))?
.join(&child_path);
if child_abs.exists() {
recurse_update(&child_abs, init_missing)?;
}
}
Ok(())
}
pub fn status(repo_path: &Path) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let subs = repo.submodules().map_err(ToriiError::Git)?;
if subs.is_empty() {
println!("📦 No submodules in this repo.");
return Ok(());
}
println!("📦 Submodules:\n");
for sm in &subs {
let name = sm.name().unwrap_or("?");
let path = sm.path().display();
let url = sm.url().unwrap_or("(no url)");
let head = sm
.head_id()
.map(|o| o.to_string()[..7].to_string())
.unwrap_or_else(|| "—".to_string());
let wd = sm
.workdir_id()
.map(|o| o.to_string()[..7].to_string())
.unwrap_or_else(|| "(not cloned)".to_string());
let state = describe_submodule_state(&repo, name).unwrap_or_else(|_| "?".to_string());
println!(" • {name}");
println!(" path: {path}");
println!(" url: {url}");
println!(" head: {head} working: {wd} state: {state}");
}
Ok(())
}
fn describe_submodule_state(repo: &Repository, name: &str) -> Result<String> {
let status = repo
.submodule_status(name, git2::SubmoduleIgnore::None)
.map_err(ToriiError::Git)?;
let mut parts = Vec::new();
if status.contains(git2::SubmoduleStatus::IN_HEAD) {
}
if !status.contains(git2::SubmoduleStatus::IN_WD) {
parts.push("not initialised".to_string());
}
if status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED) {
parts.push("uninitialised".to_string());
}
if status.contains(git2::SubmoduleStatus::WD_MODIFIED) {
parts.push("modified".to_string());
}
if status.contains(git2::SubmoduleStatus::INDEX_MODIFIED)
|| status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
{
parts.push("staged changes".to_string());
}
if status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED) {
parts.push("dirty working tree".to_string());
}
if status.contains(git2::SubmoduleStatus::WD_UNTRACKED) {
parts.push("untracked files".to_string());
}
if parts.is_empty() {
parts.push("clean".to_string());
}
Ok(parts.join(", "))
}
pub fn init(repo_path: &Path, force: bool) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let mut subs = repo.submodules().map_err(ToriiError::Git)?;
if subs.is_empty() {
println!("📦 No submodules to initialise.");
return Ok(());
}
for sm in &mut subs {
let name = sm.name().unwrap_or("?").to_string();
sm.init(force).map_err(ToriiError::Git)?;
println!("🔧 Initialised: {name}");
}
println!("\n✅ Initialised {} submodule(s).", subs.len());
Ok(())
}
#[derive(Debug, Default)]
pub struct UpdateOpts {
pub init: bool,
pub recursive: bool,
}
pub fn update(repo_path: &Path, opts: &UpdateOpts) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let mut subs = repo.submodules().map_err(ToriiError::Git)?;
if subs.is_empty() {
println!("📦 No submodules to update.");
return Ok(());
}
for sm in &mut subs {
let name = sm.name().unwrap_or("?").to_string();
let mut up_opts = SubmoduleUpdateOptions::new();
sm.update(opts.init, Some(&mut up_opts))
.map_err(|e| ToriiError::InvalidConfig(format!("update {name}: {e}")))?;
let at = sm
.workdir_id()
.map(|o| o.to_string()[..7].to_string())
.unwrap_or_else(|| "?".to_string());
println!("⬆ {name} → {at}");
if opts.recursive {
let child = sm.path().to_path_buf();
let child_abs = repo
.workdir()
.ok_or_else(|| ToriiError::InvalidConfig("bare repo".into()))?
.join(&child);
if child_abs.exists() {
recurse_update(&child_abs, opts.init)?;
}
}
}
println!("\n✅ Updated {} submodule(s){}.", subs.len(),
if opts.recursive { " (recursive)" } else { "" });
Ok(())
}
pub fn sync(repo_path: &Path) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let mut subs = repo.submodules().map_err(ToriiError::Git)?;
if subs.is_empty() {
println!("📦 No submodules to sync.");
return Ok(());
}
for sm in &mut subs {
let name = sm.name().unwrap_or("?").to_string();
sm.sync().map_err(ToriiError::Git)?;
println!("🔄 Synced: {name}");
}
println!("\n✅ Synced {} submodule(s).", subs.len());
Ok(())
}
pub fn foreach(repo_path: &Path, cmd: &str) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let subs = repo.submodules().map_err(ToriiError::Git)?;
if subs.is_empty() {
println!("📦 No submodules.");
return Ok(());
}
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
let workdir = repo
.workdir()
.ok_or_else(|| ToriiError::InvalidConfig("bare repo".into()))?
.to_path_buf();
for sm in &subs {
let name = sm.name().unwrap_or("?").to_string();
let path = sm.path().to_path_buf();
let abs = workdir.join(&path);
if !abs.exists() {
println!("⏭ {name} (not initialised, skipping)");
continue;
}
println!("▶ {name} ({})", path.display());
let status = Command::new(&shell)
.args(["-c", cmd])
.current_dir(&abs)
.env("TORII_SUBMODULE_NAME", &name)
.env("TORII_SUBMODULE_PATH", path.to_string_lossy().as_ref())
.status()
.map_err(|e| ToriiError::InvalidConfig(format!("spawn shell: {e}")))?;
if !status.success() {
return Err(ToriiError::InvalidConfig(format!(
"foreach stopped: '{cmd}' exited {status} in {name}"
)));
}
}
Ok(())
}
pub fn remove(repo_path: &Path, path: &Path) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let workdir = repo
.workdir()
.ok_or_else(|| ToriiError::InvalidConfig("bare repo".into()))?
.to_path_buf();
let subs = repo.submodules().map_err(ToriiError::Git)?;
let target = subs
.iter()
.find(|s| s.path() == path)
.ok_or_else(|| {
ToriiError::InvalidConfig(format!(
"{} is not a known submodule. Run 'torii submodule status' to list.",
path.display()
))
})?;
let name = target.name().unwrap_or("?").to_string();
let path = target.path().to_path_buf();
let gitmodules = workdir.join(".gitmodules");
if gitmodules.exists() {
strip_section_from_ini(&gitmodules, &format!("submodule \"{name}\""))?;
}
let git_config = repo.path().join("config");
strip_section_from_ini(&git_config, &format!("submodule \"{name}\""))?;
let cached_gitdir = repo.path().join("modules").join(&name);
if cached_gitdir.exists() {
std::fs::remove_dir_all(&cached_gitdir).map_err(|e| {
ToriiError::InvalidConfig(format!(
"remove cached gitdir {}: {}",
cached_gitdir.display(),
e
))
})?;
}
let abs_path = workdir.join(&path);
{
let mut index = repo.index().map_err(ToriiError::Git)?;
let _ = index.remove_path(&path);
let _ = index.remove_dir(&path, 0);
index.write().map_err(ToriiError::Git)?;
}
if abs_path.exists() {
std::fs::remove_dir_all(&abs_path).ok();
}
println!(
"🗑 Submodule '{}' deregistered.\n Stage the result and commit: torii save -am \"remove submodule {}\"",
name,
path.display()
);
Ok(())
}
fn strip_section_from_ini(file: &Path, section: &str) -> Result<()> {
if !file.exists() {
return Ok(());
}
let content = std::fs::read_to_string(file).map_err(|e| {
ToriiError::InvalidConfig(format!("read {}: {}", file.display(), e))
})?;
let target_header = format!("[{section}]");
let mut out = String::with_capacity(content.len());
let mut skipping = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == target_header {
skipping = true;
continue;
}
if skipping {
if trimmed.starts_with('[') && trimmed.ends_with(']') {
skipping = false;
} else {
continue;
}
}
out.push_str(line);
out.push('\n');
}
std::fs::write(file, out).map_err(|e| {
ToriiError::InvalidConfig(format!("write {}: {}", file.display(), e))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn strip_section_removes_block_only() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(
tmp,
"[core]\n\trepositoryformatversion = 0\n[submodule \"vendor/x\"]\n\turl = a\n\tpath = vendor/x\n[remote \"origin\"]\n\turl = b"
)
.unwrap();
strip_section_from_ini(tmp.path(), "submodule \"vendor/x\"").unwrap();
let out = std::fs::read_to_string(tmp.path()).unwrap();
assert!(out.contains("[core]"));
assert!(out.contains("[remote \"origin\"]"));
assert!(!out.contains("[submodule"));
assert!(!out.contains("vendor/x"));
}
#[test]
fn strip_section_no_op_on_missing() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp, "[core]\n\trepositoryformatversion = 0\n").unwrap();
strip_section_from_ini(tmp.path(), "submodule \"absent\"").unwrap();
let out = std::fs::read_to_string(tmp.path()).unwrap();
assert!(out.contains("[core]"));
}
#[test]
fn strip_section_handles_eof_after_block() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(
tmp,
"[core]\n\trepositoryformatversion = 0\n[submodule \"x\"]\n\turl = u\n"
)
.unwrap();
strip_section_from_ini(tmp.path(), "submodule \"x\"").unwrap();
let out = std::fs::read_to_string(tmp.path()).unwrap();
assert!(!out.contains("submodule"));
}
}