//! `vcs submodule`: git submodules, which vcs keeps as a pointer to a //! commit. A workspace shows each as an empty folder, as git does before //! `git submodule update`; `--checkout` clones it there at its commit, and //! nothing inside it is ever saved. Pointing one at another commit is a //! change like any other: `vcs submodule `, or with no //! commit, the one checked out in its folder. use std::path::Path; use serde_json::json; use vcs_core::tree::{self, Entry, EntryKind}; use crate::app::{App, Session}; use crate::out::{Fail, Out, Report}; use crate::ws::{Index, IndexEntry, Stat}; /// `vcs submodule [path [commit]] [--url ] [--remove] [--checkout]`. pub fn run(app: &App, path: Option, commit: Option, url: Option, remove: bool, checkout: bool) -> Out { let mut s = app.session()?; app.save(&mut s)?; let path = path.map(|p| p.trim_matches('/').to_string()); if let Some(p) = &path && !tree::valid_path(p) { return Err(Fail::new("bad_usage", format!("{p:?} isn't a path in the project")).state("nothing changed")); } if checkout { return check_out(app, &s, path.as_deref()); } let Some(path) = path else { return list(app, &s) }; if remove { return take_out(app, &mut s, &path); } set(app, &mut s, &path, commit, url) } /// The project's submodules in this change: (path, commit). fn submodules(app: &App, s: &Session) -> Result, Fail> { let index = Index::load(&s.root); let mut out = Vec::new(); for (path, e) in &index.entries { if e.entry.kind == EntryKind::Gitlink { out.push((path.clone(), String::from_utf8_lossy(&app.store.read_file(&e.entry.id)?).trim().to_string())); } } Ok(out) } fn list(app: &App, s: &Session) -> Out { let modules = gitmodules(&s.root); let mut r = Report::new(); let found = submodules(app, s)?; for (path, commit) in &found { let url = modules.iter().find(|m| m.path == *path).map(|m| m.url.clone()).unwrap_or_default(); let here = checked_out(&s.root.join(path)); let note = match &here { Some(h) if h != commit => format!(" (checked out at {}: vcs submodule {path} records it)", short(h)), Some(_) => " (checked out)".into(), None => String::new(), }; r.line("submodule", format!("{path} {} {url}{note}", short(commit))); } if found.is_empty() { r.line("submodules", "none in this project"); } r.set("submodules", found.iter().map(|(p, c)| json!({ "path": p, "commit": c, "url": modules.iter().find(|m| m.path == *p).map(|m| m.url.clone()) })).collect::>()); Ok(r) } fn short(commit: &str) -> &str { &commit[..commit.len().min(12)] } /// The commit checked out in a folder, if it's a git checkout. fn checked_out(dir: &Path) -> Option { if !dir.join(".git").exists() { return None; } let out = std::process::Command::new("git").arg("-C").arg(dir).args(["rev-parse", "HEAD"]).output().ok()?; out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) } fn is_commit(text: &str) -> bool { (text.len() == 40 || text.len() == 64) && text.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) } /// Points the submodule at `path` at a commit (adding it, with `url`). fn set(app: &App, s: &mut Session, path: &str, commit: Option, url: Option) -> Out { let existing = submodules(app, s)?.into_iter().find(|(p, _)| p == path).map(|(_, c)| c); if existing.is_none() && url.is_none() { return Err(Fail::new("bad_usage", format!("{path} isn't a submodule here")).state("nothing changed").next(format!("vcs submodule {path} --url adds one"))); } let commit = match commit { Some(c) => c.to_ascii_lowercase(), None => checked_out(&s.root.join(path)).ok_or_else(|| { Fail::new("bad_usage", format!("{path} isn't a git checkout, so name the commit")).state("nothing changed").next(format!("vcs submodule {path} ")) })?, }; if !is_commit(&commit) { return Err(Fail::new("bad_usage", format!("{commit:?} isn't a commit: it's 40 hex digits (64 for a SHA-256 repository)")).state("nothing changed")); } let mut index = Index::load(&s.root); if existing.is_none() && index.entries.keys().any(|p| tree::under(p, path) || tree::under(path, p)) { return Err(Fail::new("bad_usage", format!("{path} has files in this change; a submodule takes a folder of its own")).state("nothing changed")); } let id = app.store.put_file(commit.as_bytes())?; index.entries.insert(path.to_string(), IndexEntry { entry: Entry { kind: EntryKind::Gitlink, id }, stat: Stat { size: 0, mtime_ns: 0, ino: 0 } }); index.tree = None; index.save(&s.root)?; if let Some(u) = &url { write_gitmodules(&s.root, &with_url(&std::fs::read_to_string(s.root.join(".gitmodules")).unwrap_or_default(), path, u))?; } std::fs::create_dir_all(s.root.join(path))?; let c = app.save(s)?; let mut r = Report::new(); match &existing { Some(old) if *old == commit => r.line("submodule", format!("{path} is already at {}", short(&commit))), Some(old) => r.line("submodule", format!("{path} now points at {} (was {})", short(&commit), short(old))), None => r.line("submodule", format!("{path} added, at {}", short(&commit))), }; r.set("path", path).set("commit", commit.clone()).set("was", existing).set("change", c.id.clone()); r.next("vcs land -m \"what you did\""); Ok(r) } fn take_out(app: &App, s: &mut Session, path: &str) -> Out { if !submodules(app, s)?.iter().any(|(p, _)| p == path) { return Err(Fail::new("not_found", format!("{path} isn't a submodule here")).state("nothing changed")); } let dir = s.root.join(path); if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { return Err(Fail::new("bad_usage", format!("{path} has a checkout in it, whose files would become the project's")).state("nothing changed").next(format!("move {path} out of the workspace, then vcs submodule {path} --remove"))); } let mut index = Index::load(&s.root); index.entries.remove(path); index.tree = None; index.save(&s.root)?; let _ = std::fs::remove_dir(&dir); let modules = std::fs::read_to_string(s.root.join(".gitmodules")).unwrap_or_default(); if !modules.is_empty() { write_gitmodules(&s.root, &without(&modules, path))?; } let c = app.save(s)?; let mut r = Report::new(); r.line("submodule", format!("{path} removed")); r.set("path", path).set("change", c.id.clone()); r.next("vcs land -m \"what you did\""); Ok(r) } /// Clones each submodule (or one) into its folder at its commit, with git. fn check_out(app: &App, s: &Session, only: Option<&str>) -> Out { let modules = gitmodules(&s.root); let mut r = Report::new(); let mut done = Vec::new(); for (path, commit) in submodules(app, s)?.into_iter().filter(|(p, _)| only.is_none_or(|o| o == p)) { let dir = s.root.join(&path); if checked_out(&dir).is_none() { let url = modules.iter().find(|m| m.path == path).map(|m| m.url.clone()).ok_or_else(|| { Fail::new("bad_usage", format!("{path} has no url in .gitmodules")).state(format!("{} checked out before it", done.len())).next(format!("vcs submodule {path} --url ")) })?; let url = resolve_url(app, s, &url)?; std::fs::create_dir_all(&dir)?; git(&dir, &["clone", "--quiet", "--no-checkout", &url, "."], &path)?; } if checked_out(&dir).as_deref() != Some(commit.as_str()) { // The commit may be newer than the clone: fetch it, then check it out. let _ = git(&dir, &["fetch", "--quiet", "origin", &commit], &path); git(&dir, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit], &path)?; } r.line("checked out", format!("{path} at {}", short(&commit))); done.push(path); } if done.is_empty() { r.line("submodules", "none to check out"); } r.set("checked_out", done); Ok(r) } fn git(dir: &Path, args: &[&str], path: &str) -> Result<(), Fail> { let out = std::process::Command::new("git") .arg("-C") .arg(dir) .args(args) .output() .map_err(|e| Fail::new("failed", format!("couldn't run git for {path}: {e}")).next("install git, which checking out submodules uses"))?; if !out.status.success() { return Err(Fail::new("failed", format!("git couldn't check out {path}: {}", String::from_utf8_lossy(&out.stderr).trim()))); } Ok(()) } /// A url in .gitmodules: relative ones ("../lib") are next to the project's git URL. fn resolve_url(app: &App, s: &Session, url: &str) -> Result { if !url.starts_with("./") && !url.starts_with("../") { return Ok(url.to_string()); } let base = if s.project.hosted() { crate::remote::Remote::for_project(&s.project).view()?.git } else { None }; let Some(mut base) = base.map(|b| b.trim_end_matches('/').to_string()) else { return Err(Fail::new("bad_usage", format!("{url} is relative to the project's git URL, which this project doesn't have")).next("vcs submodule --url ")); }; let _ = app; let mut rest = url; loop { if let Some(r) = rest.strip_prefix("./") { rest = r; } else if let Some(r) = rest.strip_prefix("../") { base = base.rsplit_once('/').map(|(b, _)| b.to_string()).unwrap_or(base); rest = r; } else { break; } } Ok(format!("{base}/{rest}")) } struct Module { path: String, url: String, } /// The submodules .gitmodules names (path and url). fn gitmodules(root: &Path) -> Vec { let text = std::fs::read_to_string(root.join(".gitmodules")).unwrap_or_default(); let mut out = Vec::new(); let mut current: Option = None; for line in text.lines().map(str::trim) { if line.starts_with('[') { out.extend(current.take()); current = Some(Module { path: String::new(), url: String::new() }); } else if let (Some(m), Some((k, v))) = (current.as_mut(), line.split_once('=')) { match k.trim() { "path" => m.path = v.trim().to_string(), "url" => m.url = v.trim().to_string(), _ => {} } } } out.extend(current); out.into_iter().filter(|m| !m.path.is_empty()).collect() } /// .gitmodules' sections as (first line, last line, path). fn sections(text: &str) -> Vec<(usize, usize, String)> { let lines: Vec<&str> = text.lines().collect(); let mut out = Vec::new(); let mut i = 0; while i < lines.len() { if lines[i].trim_start().starts_with('[') { let start = i; let mut path = String::new(); i += 1; while i < lines.len() && !lines[i].trim_start().starts_with('[') { if let Some((k, v)) = lines[i].split_once('=') && k.trim() == "path" { path = v.trim().to_string(); } i += 1; } out.push((start, i, path)); } else { i += 1; } } out } /// .gitmodules with `path`'s url set (its section added if it has none). fn with_url(text: &str, path: &str, url: &str) -> String { let lines: Vec = text.lines().map(str::to_string).collect(); if let Some((start, end, _)) = sections(text).into_iter().find(|(_, _, p)| p == path) { let mut out = lines.clone(); match (start + 1..end).find(|i| out[*i].split_once('=').is_some_and(|(k, _)| k.trim() == "url")) { Some(i) => out[i] = format!("\turl = {url}"), None => out.insert(end, format!("\turl = {url}")), } return out.join("\n") + "\n"; } let mut out = text.trim_end().to_string(); if !out.is_empty() { out.push('\n'); } out.push_str(&format!("[submodule \"{path}\"]\n\tpath = {path}\n\turl = {url}\n")); out } fn without(text: &str, path: &str) -> String { let Some((start, end, _)) = sections(text).into_iter().find(|(_, _, p)| p == path) else { return text.to_string() }; let lines: Vec<&str> = text.lines().collect(); let kept: Vec<&str> = lines[..start].iter().chain(&lines[end..]).copied().collect(); if kept.iter().all(|l| l.trim().is_empty()) { String::new() } else { kept.join("\n") + "\n" } } fn write_gitmodules(root: &Path, text: &str) -> Result<(), Fail> { let file = root.join(".gitmodules"); if text.trim().is_empty() { let _ = std::fs::remove_file(file); } else { std::fs::write(file, text)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn gitmodules_edits() { let text = "[submodule \"vendor/lib\"]\n\tpath = vendor/lib\n\turl = ../lib\n"; assert_eq!(with_url(text, "vendor/lib", "https://x/lib.git"), "[submodule \"vendor/lib\"]\n\tpath = vendor/lib\n\turl = https://x/lib.git\n"); let two = with_url(text, "vendor/other", "https://x/other.git"); assert!(two.contains("[submodule \"vendor/other\"]\n\tpath = vendor/other\n\turl = https://x/other.git\n")); assert_eq!(without(&two, "vendor/other"), text); assert_eq!(without(text, "vendor/lib"), ""); } }