Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 | //! folder, so every workspace shares its objects), and each workspace is one |
| 7 | //! of its linked worktrees, checked out at the version its change started |
| 8 | //! from. So `git status` and `git diff` show the change's own edits, and the |
| 9 | //! folder's files never move: Onset only writes git's index and HEAD. When a |
| 10 | //! command moves the change's base (get, new, switch, sync, land, undo), the |
| 11 | //! view follows, in the background so the command doesn't wait. |
| 12 | //! |
| 13 | //! git here is a view, not a way in: its pre-commit and pre-push hooks say |
| 14 | //! which `on` command does that, so people and agents used to git find it. |
| 15 | //! `ONSET_GIT=0` (or `git = false` under `[workspace]` in onset.toml) turns it |
| 16 | //! off; without git installed it does nothing. |
| 17 |
| 18 | use std::collections::HashMap; |
| 19 | use std::io::Write; |
| 20 | use std::path::{Path, PathBuf}; |
| 21 | use std::process::{Command, Stdio}; |
| 22 |
| 23 | use anyhow::{Context, Result, anyhow}; |
| 24 | use onset_core::git::{self as og, GitKind}; |
| 25 | use onset_core::tree::EntryKind; |
| 26 | use onset_core::{Id, tree}; |
| 27 | use rusqlite::{OptionalExtension, params}; |
| 28 |
| 29 | use crate::app::App; |
| 30 | use crate::db::Db; |
| 31 | use crate::{paths, ws}; |
| 32 |
| 33 | /// What a workspace's `.git` was last made for: its base tree. |
| 34 | const STATE: &str = "git-view"; |
| 35 |
| 36 | fn enabled(root: &Path) -> bool { |
| 37 | if paths::var("GIT").is_some_and(|v| v == "0") { |
| 38 | return false; |
| 39 | } |
| 40 | // onset.toml: [workspace] git = false |
| 41 | let toml = std::fs::read_to_string(root.join("onset.toml")).or_else(|_| std::fs::read_to_string(root.join("vcs.toml"))).unwrap_or_default(); |
| 42 | let mut in_ws = false; |
| 43 | for line in toml.lines() { |
| 44 | let l = line.trim(); |
| 45 | if l.starts_with('[') { |
| 46 | in_ws = l == "[workspace]"; |
| 47 | } else if in_ws && l.replace(' ', "") == "git=false" { |
| 48 | return false; |
| 49 | } |
| 50 | } |
| 51 | true |
| 52 | } |
| 53 |
| 54 | fn git_found() -> bool { |
| 55 | static FOUND: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); |
| 56 | *FOUND.get_or_init(|| Command::new("git").arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())) |
| 57 | } |
| 58 |
| 59 | /// The project's git repository on this machine. |
| 60 | fn shared(project: &str) -> PathBuf { |
| 61 | let safe: String = project.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect(); |
| 62 | paths::home().join("git").join(format!("{safe}.git")) |
| 63 | } |
| 64 |
| 65 | /// After a command: each workspace it worked in gets its `.git` brought up |
| 66 | /// to date, if its change's base moved. Cheap when nothing moved. |
| 67 | pub fn follow(app: &App, root: &Path) { |
| 68 | let _ = (|| -> Result<()> { |
| 69 | if !enabled(root) || crate::lazy::mounted(root) { |
| 70 | return Ok(()); |
| 71 | } |
| 72 | let git = root.join(".git"); |
| 73 | // Someone's own git repository here: it's theirs. |
| 74 | if git.is_dir() { |
| 75 | return Ok(()); |
| 76 | } |
| 77 | let id = ws::read_id(root)?; |
| 78 | let Some(w) = app.db.workspace(&id)? else { return Ok(()) }; |
| 79 | let Ok(c) = app.change(&w.change) else { return Ok(()) }; |
| 80 | let state = root.join(ws::DIR).join(STATE); |
| 81 | if git.is_file() && std::fs::read_to_string(&state).ok().as_deref() == Some(c.base_tree.hex().as_str()) { |
| 82 | return Ok(()); |
| 83 | } |
| 84 | if !git_found() { |
| 85 | return Ok(()); |
| 86 | } |
| 87 | if paths::var("GIT_WAIT").is_some_and(|v| v == "1") { |
| 88 | return update(app, root); |
| 89 | } |
| 90 | // In the background: a big project's first view takes a few seconds. |
| 91 | let exe = std::env::current_exe()?; |
| 92 | let mut cmd = Command::new(exe); |
| 93 | cmd.arg("git-view").arg(root).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); |
| 94 | crate::sys::detach(&mut cmd); |
| 95 | cmd.spawn()?; |
| 96 | Ok(()) |
| 97 | })(); |
| 98 | } |
| 99 |
| 100 | /// `on git-view <folder>` (hidden; what `follow` starts): brings the |
| 101 | /// workspace's `.git` up to date with its change's base. |
| 102 | pub fn update(app: &App, root: &Path) -> Result<()> { |
| 103 | // Read without the workspace's lock: commands meanwhile never wait on |
| 104 | // this, and one that moves the base again starts another update after. |
| 105 | let root = crate::sys::canonical(root).unwrap_or_else(|_| root.to_path_buf()); |
| 106 | let id = ws::read_id(&root)?; |
| 107 | let w = app.db.workspace(&id)?.ok_or_else(|| anyhow!("{} isn't a workspace here", root.display()))?; |
| 108 | let c = app.change(&w.change).map_err(|f| anyhow!(f.message))?; |
| 109 | struct S { |
| 110 | root: PathBuf, |
| 111 | ws: crate::db::Workspace, |
| 112 | project: crate::db::Project, |
| 113 | } |
| 114 | let project = app.db.project(&w.project)?.ok_or_else(|| anyhow!("this workspace's project is missing"))?; |
| 115 | let s = S { root, ws: w, project }; |
| 116 | let repo = repository(&s.project.id)?; |
| 117 | // What git shouldn't list: Onset's own folder and your own files (.env). |
| 118 | let mut exclude = String::from("/.onset/\n/.vcs/\n"); |
| 119 | for (path, _, _) in app.db.mine(&s.project.id)? { |
| 120 | exclude.push_str(&format!("/{path}\n")); |
| 121 | } |
| 122 | std::fs::create_dir_all(repo.join("info"))?; |
| 123 | std::fs::write(repo.join("info").join("exclude"), exclude)?; |
| 124 | let commit = commit_for(app, &s.project.id, &repo, &c.base_tree, c.base_version, &s.ws.only)?; |
| 125 | // The workspace as one of the repository's worktrees, at that commit. |
| 126 | let admin = repo.join("worktrees").join(&s.ws.id); |
| 127 | std::fs::create_dir_all(&admin)?; |
| 128 | let dot_git = s.root.join(".git"); |
| 129 | std::fs::write(admin.join("gitdir"), format!("{}\n", slash(&dot_git)))?; |
| 130 | std::fs::write(admin.join("commondir"), "../..\n")?; |
| 131 | std::fs::write(admin.join("HEAD"), format!("{commit}\n"))?; |
| 132 | std::fs::write(&dot_git, format!("gitdir: {}\n", slash(&admin)))?; |
| 133 | // The index says what the base holds; refreshing it reads the files' stat |
| 134 | // so git status only rereads what really changed. |
| 135 | run(git_at(&admin, &s.root).args(["read-tree", &commit]))?; |
| 136 | let _ = git_at(&admin, &s.root).args(["update-index", "-q", "--refresh"]).stdout(Stdio::null()).stderr(Stdio::null()).status(); |
| 137 | std::fs::write(s.root.join(ws::DIR).join(STATE), c.base_tree.hex())?; |
| 138 | Ok(()) |
| 139 | } |
| 140 |
| 141 | /// The project's git repository on this machine, made if it isn't there: |
| 142 | /// what workspaces' `.git` views and `on git mirror` share. |
| 143 | pub fn repository(project: &str) -> Result<PathBuf> { |
| 144 | let repo = shared(project); |
| 145 | if !repo.join("HEAD").exists() { |
| 146 | std::fs::create_dir_all(&repo)?; |
| 147 | run(Command::new("git").args(["init", "--bare", "-q"]).arg(&repo))?; |
| 148 | let config = [("core.bare", "false"), ("gc.auto", "0"), ("core.quotePath", "false"), ("advice.detachedHead", "false")]; |
| 149 | for (k, v) in config { |
| 150 | run(git_in(&repo).args(["config", k, v]))?; |
| 151 | } |
| 152 | hooks(&repo)?; |
| 153 | } |
| 154 | Ok(repo) |
| 155 | } |
| 156 |
| 157 | /// A git blob's ID for one of the project's files, from what's been written |
| 158 | /// to its repository before (None: not yet). |
| 159 | pub fn known_blob(db: &Db, project: &str, file: &Id) -> Result<Option<String>> { |
| 160 | table(db)?; |
| 161 | Ok(db.conn.query_row("SELECT sha1 FROM git_view_blobs WHERE project = ?1 AND onset = ?2", params![project, file.hex()], |r| r.get(0)).optional()?) |
| 162 | } |
| 163 |
| 164 | /// Remembers that the project's repository has `file` as blob `sha1`. |
| 165 | pub fn remember_blob(db: &Db, project: &str, file: &Id, sha1: &str) -> Result<()> { |
| 166 | table(db)?; |
| 167 | db.conn.execute("INSERT OR REPLACE INTO git_view_blobs (project, onset, sha1) VALUES (?1, ?2, ?3)", params![project, file.hex(), sha1])?; |
| 168 | Ok(()) |
| 169 | } |
| 170 |
| 171 | /// A path as git fast-import reads it. |
| 172 | pub fn fast_import_path(path: &str) -> String { |
| 173 | quote(path) |
| 174 | } |
| 175 |
| 176 | fn slash(p: &Path) -> String { |
| 177 | p.to_string_lossy().replace('\\', "/") |
| 178 | } |
| 179 |
| 180 | fn git_in(repo: &Path) -> Command { |
| 181 | let mut c = Command::new("git"); |
| 182 | c.env("GIT_DIR", repo).env_remove("GIT_WORK_TREE").env_remove("GIT_INDEX_FILE"); |
| 183 | c |
| 184 | } |
| 185 |
| 186 | fn git_at(admin: &Path, root: &Path) -> Command { |
| 187 | let mut c = Command::new("git"); |
| 188 | c.env("GIT_DIR", admin).env("GIT_WORK_TREE", root).env_remove("GIT_INDEX_FILE").current_dir(root); |
| 189 | c |
| 190 | } |
| 191 |
| 192 | fn run(cmd: &mut Command) -> Result<String> { |
| 193 | let out = cmd.stdin(Stdio::null()).output().context("running git")?; |
| 194 | if !out.status.success() { |
| 195 | return Err(anyhow!("git: {}", String::from_utf8_lossy(&out.stderr).trim())); |
| 196 | } |
| 197 | Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) |
| 198 | } |
| 199 |
| 200 | /// Hooks that answer git habits with the Onset command that does it. |
| 201 | fn hooks(repo: &Path) -> Result<()> { |
| 202 | let dir = repo.join("hooks"); |
| 203 | std::fs::create_dir_all(&dir)?; |
| 204 | let say = |what: &str, next: &str| { |
| 205 | format!( |
| 206 | "#!/bin/sh\necho \"onset: this folder is an Onset workspace, and git here is a read-only view for editors and tools ({what} does nothing for Onset).\" >&2\necho \"onset: your edits save themselves. {next}\" >&2\nexit 1\n" |
| 207 | ) |
| 208 | }; |
| 209 | let hooks = [ |
| 210 | ("pre-commit", say("a commit", "To put them on main: on land -m \\\"what you did\\\". To name the change: on describe \\\"what you did\\\".")), |
| 211 | ("pre-push", say("a push", "To put your change on main: on land -m \\\"what you did\\\".")), |
| 212 | ("pre-merge-commit", say("a merge", "To bring in main: on sync.")), |
| 213 | ("pre-rebase", say("a rebase", "To bring in main: on sync.")), |
| 214 | ]; |
| 215 | for (name, body) in hooks { |
| 216 | let path = dir.join(name); |
| 217 | std::fs::write(&path, body)?; |
| 218 | #[cfg(unix)] |
| 219 | { |
| 220 | use std::os::unix::fs::PermissionsExt; |
| 221 | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?; |
| 222 | } |
| 223 | } |
| 224 | Ok(()) |
| 225 | } |
| 226 |
| 227 | fn table(db: &Db) -> Result<()> { |
| 228 | db.conn.execute_batch( |
| 229 | "CREATE TABLE IF NOT EXISTS git_view_blobs (project TEXT NOT NULL, onset TEXT NOT NULL, sha1 TEXT NOT NULL, PRIMARY KEY (project, onset)); |
| 230 | CREATE TABLE IF NOT EXISTS git_view_trees (project TEXT NOT NULL, tree TEXT NOT NULL, only TEXT NOT NULL, sha1 TEXT NOT NULL, PRIMARY KEY (project, tree, only));", |
| 231 | )?; |
| 232 | Ok(()) |
| 233 | } |
| 234 |
| 235 | /// The git commit holding `tree` in the project's repository: made once |
| 236 | /// (with git fast-import, writing only files git doesn't have yet) and |
| 237 | /// remembered. Its parent is the commit of the latest earlier version made |
| 238 | /// here, so `git log` reads as main's versions. |
| 239 | fn commit_for(app: &App, project: &str, repo: &Path, base: &Id, version: Option<u64>, only: &[String]) -> Result<String> { |
| 240 | table(&app.db)?; |
| 241 | let only_key = only.join("\n"); |
| 242 | let known: Option<String> = app |
| 243 | .db |
| 244 | .conn |
| 245 | .query_row("SELECT sha1 FROM git_view_trees WHERE project = ?1 AND tree = ?2 AND only = ?3", params![project, base.hex(), only_key], |r| r.get(0)) |
| 246 | .optional()?; |
| 247 | if let Some(sha) = &known |
| 248 | && run(git_in(repo).args(["cat-file", "-e", &format!("{sha}^{{commit}}")])).is_ok() |
| 249 | { |
| 250 | return Ok(sha.clone()); |
| 251 | } |
| 252 | // A change started on top of what you just landed names no version: the |
| 253 | // version with the very same files is the one. |
| 254 | let version = match version { |
| 255 | Some(n) => Some(n), |
| 256 | None => app |
| 257 | .db |
| 258 | .conn |
| 259 | .query_row("SELECT n FROM versions WHERE project = ?1 AND tree = ?2 ORDER BY n DESC LIMIT 1", params![project, base.hex()], |r| r.get::<_, i64>(0)) |
| 260 | .optional()? |
| 261 | .map(|n| n as u64), |
| 262 | }; |
| 263 | let files = app.files(base)?; |
| 264 | let files = app.scoped(only, &files); |
| 265 | let seals = crate::seal::Seals::load(&app.db, project)?; |
| 266 | let unreadable = seals.unreadable(); |
| 267 | let mut blobs: HashMap<String, String> = { |
| 268 | let mut stmt = app.db.conn.prepare("SELECT onset, sha1 FROM git_view_blobs WHERE project = ?1")?; |
| 269 | let rows = stmt.query_map([project], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; |
| 270 | rows.collect::<rusqlite::Result<_>>()? |
| 271 | }; |
| 272 | let mut child = git_in(repo) |
| 273 | .args(["fast-import", "--quiet", "--date-format=raw", "--force"]) |
| 274 | .stdin(Stdio::piped()) |
| 275 | .stdout(Stdio::null()) |
| 276 | .stderr(Stdio::piped()) |
| 277 | .spawn() |
| 278 | .context("starting git fast-import")?; |
| 279 | let mut input = std::io::BufWriter::new(child.stdin.take().unwrap()); |
| 280 | let mut lines: Vec<String> = Vec::with_capacity(files.len()); |
| 281 | let mut fresh: Vec<(String, String)> = Vec::new(); |
| 282 | let mut mark = 0u64; |
| 283 | for (path, entry) in &files { |
| 284 | if unreadable.iter().any(|f| tree::under(path, f)) { |
| 285 | continue; |
| 286 | } |
| 287 | let mode = match entry.kind { |
| 288 | EntryKind::File => "100644", |
| 289 | EntryKind::Exec => "100755", |
| 290 | EntryKind::Link => "120000", |
| 291 | // Submodules point at their commit, as in git. |
| 292 | EntryKind::Gitlink => { |
| 293 | if let Ok(data) = app.store.read_file(&entry.id) |
| 294 | && let Some(sha) = og::parse_id(&String::from_utf8_lossy(&data)) |
| 295 | { |
| 296 | lines.push(format!("M 160000 {} {}", og::hex(&sha), quote(path))); |
| 297 | } |
| 298 | continue; |
| 299 | } |
| 300 | EntryKind::Tree => continue, |
| 301 | }; |
| 302 | let key = entry.id.hex(); |
| 303 | let sha = match blobs.get(&key) { |
| 304 | Some(sha) => sha.clone(), |
| 305 | None => { |
| 306 | let Ok(data) = app.store.read_file(&entry.id) else { continue }; |
| 307 | let data = crate::seal::open(data); |
| 308 | let sha = og::hex(&og::object_id(GitKind::Blob, &data)); |
| 309 | mark += 1; |
| 310 | writeln!(input, "blob\nmark :{mark}\ndata {}", data.len())?; |
| 311 | input.write_all(&data)?; |
| 312 | input.write_all(b"\n")?; |
| 313 | fresh.push((key.clone(), sha.clone())); |
| 314 | blobs.insert(key, sha.clone()); |
| 315 | sha |
| 316 | } |
| 317 | }; |
| 318 | lines.push(format!("M {mode} {sha} {}", quote(path))); |
| 319 | } |
| 320 | // The commit, its parent the latest earlier version's. |
| 321 | let title = match version { |
| 322 | Some(n) => match app.db.version(project, n)? { |
| 323 | Some(v) => format!("main {n}: {}", v.title), |
| 324 | None => format!("main {n}"), |
| 325 | }, |
| 326 | None => "the version this change started from".to_string(), |
| 327 | }; |
| 328 | let parent: Option<String> = match version { |
| 329 | Some(n) => app |
| 330 | .db |
| 331 | .conn |
| 332 | .query_row( |
| 333 | "SELECT t.sha1 FROM git_view_trees t JOIN versions v ON v.project = t.project AND v.tree = t.tree WHERE t.project = ?1 AND t.only = ?2 AND v.n < ?3 ORDER BY v.n DESC LIMIT 1", |
| 334 | params![project, only_key, n as i64], |
| 335 | |r| r.get(0), |
| 336 | ) |
| 337 | .optional()?, |
| 338 | None => None, |
| 339 | }; |
| 340 | let time = version.and_then(|n| app.db.version(project, n).ok().flatten()).map(|v| v.time).unwrap_or_else(crate::ids::now); |
| 341 | let ref_name = format!("refs/onset/{}", base.hex()); |
| 342 | writeln!(input, "commit {ref_name}\nmark :{}\ncommitter Onset <onset@localhost> {time} +0000\ndata {}\n{title}", mark + 1, title.len())?; |
| 343 | if let Some(p) = &parent |
| 344 | && run(git_in(repo).args(["cat-file", "-e", &format!("{p}^{{commit}}")])).is_ok() |
| 345 | { |
| 346 | writeln!(input, "from {p}")?; |
| 347 | } |
| 348 | writeln!(input, "deleteall")?; |
| 349 | for l in &lines { |
| 350 | writeln!(input, "{l}")?; |
| 351 | } |
| 352 | writeln!(input)?; |
| 353 | input.flush()?; |
| 354 | drop(input); |
| 355 | let out = child.wait_with_output()?; |
| 356 | if !out.status.success() { |
| 357 | return Err(anyhow!("git fast-import: {}", String::from_utf8_lossy(&out.stderr).trim())); |
| 358 | } |
| 359 | let sha = run(git_in(repo).args(["rev-parse", &ref_name]))?; |
| 360 | let tx = app.db.conn.unchecked_transaction()?; |
| 361 | for (onset, sha1) in &fresh { |
| 362 | tx.execute("INSERT OR REPLACE INTO git_view_blobs (project, onset, sha1) VALUES (?1, ?2, ?3)", params![project, onset, sha1])?; |
| 363 | } |
| 364 | tx.execute("INSERT OR REPLACE INTO git_view_trees (project, tree, only, sha1) VALUES (?1, ?2, ?3, ?4)", params![project, base.hex(), only_key, sha])?; |
| 365 | tx.commit()?; |
| 366 | Ok(sha) |
| 367 | } |
| 368 |
| 369 | /// A path as fast-import reads it: quoted when it has to be. |
| 370 | fn quote(path: &str) -> String { |
| 371 | if path.contains(['"', '\n', '\\']) || path.starts_with(' ') { |
| 372 | format!("\"{}\"", path.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")) |
| 373 | } else { |
| 374 | path.to_string() |
| 375 | } |
| 376 | } |
| 377 |
| 378 | /// When a workspace goes away (on workspaces clean): its worktree entry too. |
| 379 | pub fn forget(project: &str, workspace: &str) { |
| 380 | let _ = std::fs::remove_dir_all(shared(project).join("worktrees").join(workspace)); |
| 381 | } |
| 382 |