//! A `.git` in every workspace, for editors and tools that read git: change //! markers in the gutter, GitLens, `git status`, `git diff`, `gh`, hooks and //! build tools that ask git what changed. //! //! Each project has one git repository on this machine (in Onset's own //! folder, so every workspace shares its objects), and each workspace is one //! of its linked worktrees, checked out at the version its change started //! from. So `git status` and `git diff` show the change's own edits, and the //! folder's files never move: Onset only writes git's index and HEAD. When a //! command moves the change's base (get, new, switch, sync, land, undo), the //! view follows, in the background so the command doesn't wait. //! //! git here is a view, not a way in: its pre-commit and pre-push hooks say //! which `on` command does that, so people and agents used to git find it. //! `ONSET_GIT=0` (or `git = false` under `[workspace]` in onset.toml) turns it //! off; without git installed it does nothing. use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, anyhow}; use onset_core::git::{self as og, GitKind}; use onset_core::tree::EntryKind; use onset_core::{Id, tree}; use rusqlite::{OptionalExtension, params}; use crate::app::App; use crate::db::Db; use crate::{paths, ws}; /// What a workspace's `.git` was last made for: its base tree. const STATE: &str = "git-view"; fn enabled(root: &Path) -> bool { if paths::var("GIT").is_some_and(|v| v == "0") { return false; } // onset.toml: [workspace] git = false 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(); let mut in_ws = false; for line in toml.lines() { let l = line.trim(); if l.starts_with('[') { in_ws = l == "[workspace]"; } else if in_ws && l.replace(' ', "") == "git=false" { return false; } } true } fn git_found() -> bool { static FOUND: std::sync::OnceLock = std::sync::OnceLock::new(); *FOUND.get_or_init(|| Command::new("git").arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())) } /// The project's git repository on this machine. fn shared(project: &str) -> PathBuf { let safe: String = project.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect(); paths::home().join("git").join(format!("{safe}.git")) } /// After a command: each workspace it worked in gets its `.git` brought up /// to date, if its change's base moved. Cheap when nothing moved. pub fn follow(app: &App, root: &Path) { let _ = (|| -> Result<()> { if !enabled(root) || crate::lazy::mounted(root) { return Ok(()); } let git = root.join(".git"); // Someone's own git repository here: it's theirs. if git.is_dir() { return Ok(()); } let id = ws::read_id(root)?; let Some(w) = app.db.workspace(&id)? else { return Ok(()) }; let Ok(c) = app.change(&w.change) else { return Ok(()) }; let state = root.join(ws::DIR).join(STATE); if git.is_file() && std::fs::read_to_string(&state).ok().as_deref() == Some(c.base_tree.hex().as_str()) { return Ok(()); } if !git_found() { return Ok(()); } if paths::var("GIT_WAIT").is_some_and(|v| v == "1") { return update(app, root); } // In the background: a big project's first view takes a few seconds. let exe = std::env::current_exe()?; let mut cmd = Command::new(exe); cmd.arg("git-view").arg(root).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); crate::sys::detach(&mut cmd); cmd.spawn()?; Ok(()) })(); } /// `on git-view ` (hidden; what `follow` starts): brings the /// workspace's `.git` up to date with its change's base. pub fn update(app: &App, root: &Path) -> Result<()> { // Read without the workspace's lock: commands meanwhile never wait on // this, and one that moves the base again starts another update after. let root = crate::sys::canonical(root).unwrap_or_else(|_| root.to_path_buf()); let id = ws::read_id(&root)?; let w = app.db.workspace(&id)?.ok_or_else(|| anyhow!("{} isn't a workspace here", root.display()))?; let c = app.change(&w.change).map_err(|f| anyhow!(f.message))?; struct S { root: PathBuf, ws: crate::db::Workspace, project: crate::db::Project, } let project = app.db.project(&w.project)?.ok_or_else(|| anyhow!("this workspace's project is missing"))?; let s = S { root, ws: w, project }; let repo = repository(&s.project.id)?; // What git shouldn't list: Onset's own folder and your own files (.env). let mut exclude = String::from("/.onset/\n/.vcs/\n"); for (path, _, _) in app.db.mine(&s.project.id)? { exclude.push_str(&format!("/{path}\n")); } std::fs::create_dir_all(repo.join("info"))?; std::fs::write(repo.join("info").join("exclude"), exclude)?; let commit = commit_for(app, &s.project.id, &repo, &c.base_tree, c.base_version, &s.ws.only)?; // The workspace as one of the repository's worktrees, at that commit. let admin = repo.join("worktrees").join(&s.ws.id); std::fs::create_dir_all(&admin)?; let dot_git = s.root.join(".git"); std::fs::write(admin.join("gitdir"), format!("{}\n", slash(&dot_git)))?; std::fs::write(admin.join("commondir"), "../..\n")?; std::fs::write(admin.join("HEAD"), format!("{commit}\n"))?; std::fs::write(&dot_git, format!("gitdir: {}\n", slash(&admin)))?; // The index says what the base holds; refreshing it reads the files' stat // so git status only rereads what really changed. run(git_at(&admin, &s.root).args(["read-tree", &commit]))?; let _ = git_at(&admin, &s.root).args(["update-index", "-q", "--refresh"]).stdout(Stdio::null()).stderr(Stdio::null()).status(); std::fs::write(s.root.join(ws::DIR).join(STATE), c.base_tree.hex())?; Ok(()) } /// The project's git repository on this machine, made if it isn't there: /// what workspaces' `.git` views and `on git mirror` share. pub fn repository(project: &str) -> Result { let repo = shared(project); if !repo.join("HEAD").exists() { std::fs::create_dir_all(&repo)?; run(Command::new("git").args(["init", "--bare", "-q"]).arg(&repo))?; let config = [("core.bare", "false"), ("gc.auto", "0"), ("core.quotePath", "false"), ("advice.detachedHead", "false")]; for (k, v) in config { run(git_in(&repo).args(["config", k, v]))?; } hooks(&repo)?; } Ok(repo) } /// A git blob's ID for one of the project's files, from what's been written /// to its repository before (None: not yet). pub fn known_blob(db: &Db, project: &str, file: &Id) -> Result> { table(db)?; 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()?) } /// Remembers that the project's repository has `file` as blob `sha1`. pub fn remember_blob(db: &Db, project: &str, file: &Id, sha1: &str) -> Result<()> { table(db)?; db.conn.execute("INSERT OR REPLACE INTO git_view_blobs (project, onset, sha1) VALUES (?1, ?2, ?3)", params![project, file.hex(), sha1])?; Ok(()) } /// A path as git fast-import reads it. pub fn fast_import_path(path: &str) -> String { quote(path) } fn slash(p: &Path) -> String { p.to_string_lossy().replace('\\', "/") } fn git_in(repo: &Path) -> Command { let mut c = Command::new("git"); c.env("GIT_DIR", repo).env_remove("GIT_WORK_TREE").env_remove("GIT_INDEX_FILE"); c } fn git_at(admin: &Path, root: &Path) -> Command { let mut c = Command::new("git"); c.env("GIT_DIR", admin).env("GIT_WORK_TREE", root).env_remove("GIT_INDEX_FILE").current_dir(root); c } fn run(cmd: &mut Command) -> Result { let out = cmd.stdin(Stdio::null()).output().context("running git")?; if !out.status.success() { return Err(anyhow!("git: {}", String::from_utf8_lossy(&out.stderr).trim())); } Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } /// Hooks that answer git habits with the Onset command that does it. fn hooks(repo: &Path) -> Result<()> { let dir = repo.join("hooks"); std::fs::create_dir_all(&dir)?; let say = |what: &str, next: &str| { format!( "#!/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" ) }; let hooks = [ ("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\\\".")), ("pre-push", say("a push", "To put your change on main: on land -m \\\"what you did\\\".")), ("pre-merge-commit", say("a merge", "To bring in main: on sync.")), ("pre-rebase", say("a rebase", "To bring in main: on sync.")), ]; for (name, body) in hooks { let path = dir.join(name); std::fs::write(&path, body)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?; } } Ok(()) } fn table(db: &Db) -> Result<()> { db.conn.execute_batch( "CREATE TABLE IF NOT EXISTS git_view_blobs (project TEXT NOT NULL, onset TEXT NOT NULL, sha1 TEXT NOT NULL, PRIMARY KEY (project, onset)); 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));", )?; Ok(()) } /// The git commit holding `tree` in the project's repository: made once /// (with git fast-import, writing only files git doesn't have yet) and /// remembered. Its parent is the commit of the latest earlier version made /// here, so `git log` reads as main's versions. fn commit_for(app: &App, project: &str, repo: &Path, base: &Id, version: Option, only: &[String]) -> Result { table(&app.db)?; let only_key = only.join("\n"); let known: Option = app .db .conn .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)) .optional()?; if let Some(sha) = &known && run(git_in(repo).args(["cat-file", "-e", &format!("{sha}^{{commit}}")])).is_ok() { return Ok(sha.clone()); } // A change started on top of what you just landed names no version: the // version with the very same files is the one. let version = match version { Some(n) => Some(n), None => app .db .conn .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)) .optional()? .map(|n| n as u64), }; let files = app.files(base)?; let files = app.scoped(only, &files); let seals = crate::seal::Seals::load(&app.db, project)?; let unreadable = seals.unreadable(); let mut blobs: HashMap = { let mut stmt = app.db.conn.prepare("SELECT onset, sha1 FROM git_view_blobs WHERE project = ?1")?; let rows = stmt.query_map([project], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; rows.collect::>()? }; let mut child = git_in(repo) .args(["fast-import", "--quiet", "--date-format=raw", "--force"]) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .context("starting git fast-import")?; let mut input = std::io::BufWriter::new(child.stdin.take().unwrap()); let mut lines: Vec = Vec::with_capacity(files.len()); let mut fresh: Vec<(String, String)> = Vec::new(); let mut mark = 0u64; for (path, entry) in &files { if unreadable.iter().any(|f| tree::under(path, f)) { continue; } let mode = match entry.kind { EntryKind::File => "100644", EntryKind::Exec => "100755", EntryKind::Link => "120000", // Submodules point at their commit, as in git. EntryKind::Gitlink => { if let Ok(data) = app.store.read_file(&entry.id) && let Some(sha) = og::parse_id(&String::from_utf8_lossy(&data)) { lines.push(format!("M 160000 {} {}", og::hex(&sha), quote(path))); } continue; } EntryKind::Tree => continue, }; let key = entry.id.hex(); let sha = match blobs.get(&key) { Some(sha) => sha.clone(), None => { let Ok(data) = app.store.read_file(&entry.id) else { continue }; let data = crate::seal::open(data); let sha = og::hex(&og::object_id(GitKind::Blob, &data)); mark += 1; writeln!(input, "blob\nmark :{mark}\ndata {}", data.len())?; input.write_all(&data)?; input.write_all(b"\n")?; fresh.push((key.clone(), sha.clone())); blobs.insert(key, sha.clone()); sha } }; lines.push(format!("M {mode} {sha} {}", quote(path))); } // The commit, its parent the latest earlier version's. let title = match version { Some(n) => match app.db.version(project, n)? { Some(v) => format!("main {n}: {}", v.title), None => format!("main {n}"), }, None => "the version this change started from".to_string(), }; let parent: Option = match version { Some(n) => app .db .conn .query_row( "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", params![project, only_key, n as i64], |r| r.get(0), ) .optional()?, None => None, }; let time = version.and_then(|n| app.db.version(project, n).ok().flatten()).map(|v| v.time).unwrap_or_else(crate::ids::now); let ref_name = format!("refs/onset/{}", base.hex()); writeln!(input, "commit {ref_name}\nmark :{}\ncommitter Onset {time} +0000\ndata {}\n{title}", mark + 1, title.len())?; if let Some(p) = &parent && run(git_in(repo).args(["cat-file", "-e", &format!("{p}^{{commit}}")])).is_ok() { writeln!(input, "from {p}")?; } writeln!(input, "deleteall")?; for l in &lines { writeln!(input, "{l}")?; } writeln!(input)?; input.flush()?; drop(input); let out = child.wait_with_output()?; if !out.status.success() { return Err(anyhow!("git fast-import: {}", String::from_utf8_lossy(&out.stderr).trim())); } let sha = run(git_in(repo).args(["rev-parse", &ref_name]))?; let tx = app.db.conn.unchecked_transaction()?; for (onset, sha1) in &fresh { tx.execute("INSERT OR REPLACE INTO git_view_blobs (project, onset, sha1) VALUES (?1, ?2, ?3)", params![project, onset, sha1])?; } 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])?; tx.commit()?; Ok(sha) } /// A path as fast-import reads it: quoted when it has to be. fn quote(path: &str) -> String { if path.contains(['"', '\n', '\\']) || path.starts_with(' ') { format!("\"{}\"", path.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")) } else { path.to_string() } } /// When a workspace goes away (on workspaces clean): its worktree entry too. pub fn forget(project: &str, workspace: &str) { let _ = std::fs::remove_dir_all(shared(project).join("worktrees").join(workspace)); }