Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 | //! commit, the one checked out in its folder. |
| 7 |
| 8 | use std::path::Path; |
| 9 |
| 10 | use serde_json::json; |
| 11 | use vcs_core::tree::{self, Entry, EntryKind}; |
| 12 |
| 13 |
| 14 | use crate::app::{App, Session}; |
| 15 | use crate::out::{Fail, Out, Report}; |
| 16 | use crate::ws::{Index, IndexEntry, Stat}; |
| 17 |
| 18 | /// `vcs submodule [path [commit]] [--url <url>] [--remove] [--checkout]`. |
| 19 | pub fn run(app: &App, path: Option<String>, commit: Option<String>, url: Option<String>, remove: bool, checkout: bool) -> Out { |
| 20 | let mut s = app.session()?; |
| 21 | app.save(&mut s)?; |
| 22 | let path = path.map(|p| p.trim_matches('/').to_string()); |
| 23 | if let Some(p) = &path |
| 24 | && !tree::valid_path(p) |
| 25 | { |
| 26 | return Err(Fail::new("bad_usage", format!("{p:?} isn't a path in the project")).state("nothing changed")); |
| 27 | } |
| 28 | if checkout { |
| 29 | return check_out(app, &s, path.as_deref()); |
| 30 | } |
| 31 | let Some(path) = path else { return list(app, &s) }; |
| 32 | if remove { |
| 33 | return take_out(app, &mut s, &path); |
| 34 | } |
| 35 | set(app, &mut s, &path, commit, url) |
| 36 | } |
| 37 |
| 38 | /// The project's submodules in this change: (path, commit). |
| 39 | fn submodules(app: &App, s: &Session) -> Result<Vec<(String, String)>, Fail> { |
| 40 | let index = Index::load(&s.root); |
| 41 | let mut out = Vec::new(); |
| 42 | for (path, e) in &index.entries { |
| 43 | if e.entry.kind == EntryKind::Gitlink { |
| 44 | out.push((path.clone(), String::from_utf8_lossy(&app.store.read_file(&e.entry.id)?).trim().to_string())); |
| 45 | } |
| 46 | } |
| 47 | Ok(out) |
| 48 | } |
| 49 |
| 50 | fn list(app: &App, s: &Session) -> Out { |
| 51 | let modules = gitmodules(&s.root); |
| 52 | let mut r = Report::new(); |
| 53 | let found = submodules(app, s)?; |
| 54 | for (path, commit) in &found { |
| 55 | let url = modules.iter().find(|m| m.path == *path).map(|m| m.url.clone()).unwrap_or_default(); |
| 56 | let here = checked_out(&s.root.join(path)); |
| 57 | let note = match &here { |
| 58 | Some(h) if h != commit => format!(" (checked out at {}: vcs submodule {path} records it)", short(h)), |
| 59 | Some(_) => " (checked out)".into(), |
| 60 | None => String::new(), |
| 61 | }; |
| 62 | r.line("submodule", format!("{path} {} {url}{note}", short(commit))); |
| 63 | } |
| 64 | if found.is_empty() { |
| 65 | r.line("submodules", "none in this project"); |
| 66 | } |
| 67 | 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::<Vec<_>>()); |
| 68 | Ok(r) |
| 69 | } |
| 70 |
| 71 | fn short(commit: &str) -> &str { |
| 72 | &commit[..commit.len().min(12)] |
| 73 | } |
| 74 |
| 75 | /// The commit checked out in a folder, if it's a git checkout. |
| 76 | fn checked_out(dir: &Path) -> Option<String> { |
| 77 | if !dir.join(".git").exists() { |
| 78 | return None; |
| 79 | } |
| 80 | let out = std::process::Command::new("git").arg("-C").arg(dir).args(["rev-parse", "HEAD"]).output().ok()?; |
| 81 | out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) |
| 82 | } |
| 83 |
| 84 | fn is_commit(text: &str) -> bool { |
| 85 | (text.len() == 40 || text.len() == 64) && text.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) |
| 86 | } |
| 87 |
| 88 | /// Points the submodule at `path` at a commit (adding it, with `url`). |
| 89 | fn set(app: &App, s: &mut Session, path: &str, commit: Option<String>, url: Option<String>) -> Out { |
| 90 | let existing = submodules(app, s)?.into_iter().find(|(p, _)| p == path).map(|(_, c)| c); |
| 91 | if existing.is_none() && url.is_none() { |
| 92 | return Err(Fail::new("bad_usage", format!("{path} isn't a submodule here")).state("nothing changed").next(format!("vcs submodule {path} <commit> --url <git url> adds one"))); |
| 93 | } |
| 94 | let commit = match commit { |
| 95 | Some(c) => c.to_ascii_lowercase(), |
| 96 | None => checked_out(&s.root.join(path)).ok_or_else(|| { |
| 97 | Fail::new("bad_usage", format!("{path} isn't a git checkout, so name the commit")).state("nothing changed").next(format!("vcs submodule {path} <commit>")) |
| 98 | })?, |
| 99 | }; |
| 100 | if !is_commit(&commit) { |
| 101 | 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")); |
| 102 | } |
| 103 | let mut index = Index::load(&s.root); |
| 104 | if existing.is_none() && index.entries.keys().any(|p| tree::under(p, path) || tree::under(path, p)) { |
| 105 | return Err(Fail::new("bad_usage", format!("{path} has files in this change; a submodule takes a folder of its own")).state("nothing changed")); |
| 106 | } |
| 107 | let id = app.store.put_file(commit.as_bytes())?; |
| 108 | index.entries.insert(path.to_string(), IndexEntry { entry: Entry { kind: EntryKind::Gitlink, id }, stat: Stat { size: 0, mtime_ns: 0, ino: 0 } }); |
| 109 | index.tree = None; |
| 110 | index.save(&s.root)?; |
| 111 | if let Some(u) = &url { |
| 112 | write_gitmodules(&s.root, &with_url(&std::fs::read_to_string(s.root.join(".gitmodules")).unwrap_or_default(), path, u))?; |
| 113 | } |
| 114 | std::fs::create_dir_all(s.root.join(path))?; |
| 115 | let c = app.save(s)?; |
| 116 | let mut r = Report::new(); |
| 117 | match &existing { |
| 118 | Some(old) if *old == commit => r.line("submodule", format!("{path} is already at {}", short(&commit))), |
| 119 | Some(old) => r.line("submodule", format!("{path} now points at {} (was {})", short(&commit), short(old))), |
| 120 | None => r.line("submodule", format!("{path} added, at {}", short(&commit))), |
| 121 | }; |
| 122 | r.set("path", path).set("commit", commit.clone()).set("was", existing).set("change", c.id.clone()); |
| 123 | r.next("vcs land -m \"what you did\""); |
| 124 | Ok(r) |
| 125 | } |
| 126 |
| 127 | fn take_out(app: &App, s: &mut Session, path: &str) -> Out { |
| 128 | if !submodules(app, s)?.iter().any(|(p, _)| p == path) { |
| 129 | return Err(Fail::new("not_found", format!("{path} isn't a submodule here")).state("nothing changed")); |
| 130 | } |
| 131 | let dir = s.root.join(path); |
| 132 | if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { |
| 133 | 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"))); |
| 134 | } |
| 135 | let mut index = Index::load(&s.root); |
| 136 | index.entries.remove(path); |
| 137 | index.tree = None; |
| 138 | index.save(&s.root)?; |
| 139 | let _ = std::fs::remove_dir(&dir); |
| 140 | let modules = std::fs::read_to_string(s.root.join(".gitmodules")).unwrap_or_default(); |
| 141 | if !modules.is_empty() { |
| 142 | write_gitmodules(&s.root, &without(&modules, path))?; |
| 143 | } |
| 144 | let c = app.save(s)?; |
| 145 | let mut r = Report::new(); |
| 146 | r.line("submodule", format!("{path} removed")); |
| 147 | r.set("path", path).set("change", c.id.clone()); |
| 148 | r.next("vcs land -m \"what you did\""); |
| 149 | Ok(r) |
| 150 | } |
| 151 |
| 152 | /// Clones each submodule (or one) into its folder at its commit, with git. |
| 153 | fn check_out(app: &App, s: &Session, only: Option<&str>) -> Out { |
| 154 | let modules = gitmodules(&s.root); |
| 155 | let mut r = Report::new(); |
| 156 | let mut done = Vec::new(); |
| 157 | for (path, commit) in submodules(app, s)?.into_iter().filter(|(p, _)| only.is_none_or(|o| o == p)) { |
| 158 | let dir = s.root.join(&path); |
| 159 | if checked_out(&dir).is_none() { |
| 160 | let url = modules.iter().find(|m| m.path == path).map(|m| m.url.clone()).ok_or_else(|| { |
| 161 | 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 <git url>")) |
| 162 | })?; |
| 163 | let url = resolve_url(app, s, &url)?; |
| 164 | std::fs::create_dir_all(&dir)?; |
| 165 | git(&dir, &["clone", "--quiet", "--no-checkout", &url, "."], &path)?; |
| 166 | } |
| 167 | if checked_out(&dir).as_deref() != Some(commit.as_str()) { |
| 168 | // The commit may be newer than the clone: fetch it, then check it out. |
| 169 | let _ = git(&dir, &["fetch", "--quiet", "origin", &commit], &path); |
| 170 | git(&dir, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit], &path)?; |
| 171 | } |
| 172 | r.line("checked out", format!("{path} at {}", short(&commit))); |
| 173 | done.push(path); |
| 174 | } |
| 175 | if done.is_empty() { |
| 176 | r.line("submodules", "none to check out"); |
| 177 | } |
| 178 | r.set("checked_out", done); |
| 179 | Ok(r) |
| 180 | } |
| 181 |
| 182 | fn git(dir: &Path, args: &[&str], path: &str) -> Result<(), Fail> { |
| 183 | let out = std::process::Command::new("git") |
| 184 | .arg("-C") |
| 185 | .arg(dir) |
| 186 | .args(args) |
| 187 | .output() |
| 188 | .map_err(|e| Fail::new("failed", format!("couldn't run git for {path}: {e}")).next("install git, which checking out submodules uses"))?; |
| 189 | if !out.status.success() { |
| 190 | return Err(Fail::new("failed", format!("git couldn't check out {path}: {}", String::from_utf8_lossy(&out.stderr).trim()))); |
| 191 | } |
| 192 | Ok(()) |
| 193 | } |
| 194 |
| 195 | /// A url in .gitmodules: relative ones ("../lib") are next to the project's git URL. |
| 196 | fn resolve_url(app: &App, s: &Session, url: &str) -> Result<String, Fail> { |
| 197 | if !url.starts_with("./") && !url.starts_with("../") { |
| 198 | return Ok(url.to_string()); |
| 199 | } |
| 200 | let base = if s.project.hosted() { crate::remote::Remote::for_project(&s.project).view()?.git } else { None }; |
| 201 | let Some(mut base) = base.map(|b| b.trim_end_matches('/').to_string()) else { |
| 202 | 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 <path> --url <a whole url>")); |
| 203 | }; |
| 204 | let _ = app; |
| 205 | let mut rest = url; |
| 206 | loop { |
| 207 | if let Some(r) = rest.strip_prefix("./") { |
| 208 | rest = r; |
| 209 | } else if let Some(r) = rest.strip_prefix("../") { |
| 210 | base = base.rsplit_once('/').map(|(b, _)| b.to_string()).unwrap_or(base); |
| 211 | rest = r; |
| 212 | } else { |
| 213 | break; |
| 214 | } |
| 215 | } |
| 216 | Ok(format!("{base}/{rest}")) |
| 217 | } |
| 218 |
| 219 | struct Module { |
| 220 | path: String, |
| 221 | url: String, |
| 222 | } |
| 223 |
| 224 | /// The submodules .gitmodules names (path and url). |
| 225 | fn gitmodules(root: &Path) -> Vec<Module> { |
| 226 | let text = std::fs::read_to_string(root.join(".gitmodules")).unwrap_or_default(); |
| 227 | let mut out = Vec::new(); |
| 228 | let mut current: Option<Module> = None; |
| 229 | for line in text.lines().map(str::trim) { |
| 230 | if line.starts_with('[') { |
| 231 | out.extend(current.take()); |
| 232 | current = Some(Module { path: String::new(), url: String::new() }); |
| 233 | } else if let (Some(m), Some((k, v))) = (current.as_mut(), line.split_once('=')) { |
| 234 | match k.trim() { |
| 235 | "path" => m.path = v.trim().to_string(), |
| 236 | "url" => m.url = v.trim().to_string(), |
| 237 | _ => {} |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | out.extend(current); |
| 242 | out.into_iter().filter(|m| !m.path.is_empty()).collect() |
| 243 | } |
| 244 |
| 245 | /// .gitmodules' sections as (first line, last line, path). |
| 246 | fn sections(text: &str) -> Vec<(usize, usize, String)> { |
| 247 | let lines: Vec<&str> = text.lines().collect(); |
| 248 | let mut out = Vec::new(); |
| 249 | let mut i = 0; |
| 250 | while i < lines.len() { |
| 251 | if lines[i].trim_start().starts_with('[') { |
| 252 | let start = i; |
| 253 | let mut path = String::new(); |
| 254 | i += 1; |
| 255 | while i < lines.len() && !lines[i].trim_start().starts_with('[') { |
| 256 | if let Some((k, v)) = lines[i].split_once('=') |
| 257 | && k.trim() == "path" |
| 258 | { |
| 259 | path = v.trim().to_string(); |
| 260 | } |
| 261 | i += 1; |
| 262 | } |
| 263 | out.push((start, i, path)); |
| 264 | } else { |
| 265 | i += 1; |
| 266 | } |
| 267 | } |
| 268 | out |
| 269 | } |
| 270 |
| 271 | /// .gitmodules with `path`'s url set (its section added if it has none). |
| 272 | fn with_url(text: &str, path: &str, url: &str) -> String { |
| 273 | let lines: Vec<String> = text.lines().map(str::to_string).collect(); |
| 274 | if let Some((start, end, _)) = sections(text).into_iter().find(|(_, _, p)| p == path) { |
| 275 | let mut out = lines.clone(); |
| 276 | match (start + 1..end).find(|i| out[*i].split_once('=').is_some_and(|(k, _)| k.trim() == "url")) { |
| 277 | Some(i) => out[i] = format!("\turl = {url}"), |
| 278 | None => out.insert(end, format!("\turl = {url}")), |
| 279 | } |
| 280 | return out.join("\n") + "\n"; |
| 281 | } |
| 282 | let mut out = text.trim_end().to_string(); |
| 283 | if !out.is_empty() { |
| 284 | out.push('\n'); |
| 285 | } |
| 286 | out.push_str(&format!("[submodule \"{path}\"]\n\tpath = {path}\n\turl = {url}\n")); |
| 287 | out |
| 288 | } |
| 289 |
| 290 | fn without(text: &str, path: &str) -> String { |
| 291 | let Some((start, end, _)) = sections(text).into_iter().find(|(_, _, p)| p == path) else { return text.to_string() }; |
| 292 | let lines: Vec<&str> = text.lines().collect(); |
| 293 | let kept: Vec<&str> = lines[..start].iter().chain(&lines[end..]).copied().collect(); |
| 294 | if kept.iter().all(|l| l.trim().is_empty()) { String::new() } else { kept.join("\n") + "\n" } |
| 295 | } |
| 296 |
| 297 | fn write_gitmodules(root: &Path, text: &str) -> Result<(), Fail> { |
| 298 | let file = root.join(".gitmodules"); |
| 299 | if text.trim().is_empty() { |
| 300 | let _ = std::fs::remove_file(file); |
| 301 | } else { |
| 302 | std::fs::write(file, text)?; |
| 303 | } |
| 304 | Ok(()) |
| 305 | } |
| 306 |
| 307 | #[cfg(test)] |
| 308 | mod tests { |
| 309 | use super::*; |
| 310 |
| 311 | #[test] |
| 312 | fn gitmodules_edits() { |
| 313 | let text = "[submodule \"vendor/lib\"]\n\tpath = vendor/lib\n\turl = ../lib\n"; |
| 314 | 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"); |
| 315 | let two = with_url(text, "vendor/other", "https://x/other.git"); |
| 316 | assert!(two.contains("[submodule \"vendor/other\"]\n\tpath = vendor/other\n\turl = https://x/other.git\n")); |
| 317 | assert_eq!(without(&two, "vendor/other"), text); |
| 318 | assert_eq!(without(text, "vendor/lib"), ""); |
| 319 | } |
| 320 | } |
| 321 |