//! System-`git`-backed version control for OpenPencil documents. //! //! This crate is the Rust counterpart of the TS Electron app's //! in-app Git (`apps/desktop/git/`). It drives the user's installed //! `git` executable through `std::process::Command` — the same //! approach as the TS `git-sys.ts` backend — so no `libgit2` / //! `git2` C dependency is pulled in. //! //! ## Scope //! //! The full TS surface spans repo lifecycle, branches, history, //! remotes, merge orchestration, worktree merges, auth + SSH keys. //! This module is the **foundation layer**: repo discovery / init, //! working-tree status, staging, commit, restore, branch list / //! create / delete / switch, and commit history / diff. Remote //! operations, merge orchestration and credential handling land in //! sibling modules in later increments. //! //! Every operation returns a [`GitError`] on failure — a missing //! `git`, a non-repo path, or a non-zero `git` exit (with its //! stderr) — and never panics. use std::path::{Path, PathBuf}; use std::process::{Command, Output}; mod auth; mod branch; mod history; mod merge; mod remote; mod ssh; mod status; mod worktree; pub use auth::{AuthStore, Credential}; pub use branch::Branch; pub use history::Commit; pub use merge::{ ConflictBag, ConflictKind, ConflictStages, ConflictedFile, MergeOutcome, WorktreeMergeReport, }; pub use remote::Remote; pub use ssh::{SshKey, SshKeyStore}; pub use status::{ChangeState, FileStatus, RepoStatus}; /// An error from a git operation. #[derive(Debug, thiserror::Error)] pub enum GitError { /// The `git` executable could not be found on `PATH`. #[error("the `git` executable was not found — install Git to use version control")] GitNotFound, /// The path is not inside a git repository. #[error("not a git repository: {0}")] NotARepo(PathBuf), /// `git` ran but exited non-zero. #[error("git {operation} failed: {stderr}")] Command { /// The git subcommand that failed (`status`, `commit`, …). operation: String, /// Trimmed stderr from the failed invocation. stderr: String, }, /// An operation that needs a clean state was attempted while a /// merge is still in progress (unresolved or uncommitted). #[error("a merge is already in progress — resolve or abort it first")] MergeInProgress, /// A merge was attempted while the working tree had uncommitted /// changes — the caller must commit or stash them first. #[error("the working tree has uncommitted changes — commit or stash them before merging")] WorkingTreeDirty, /// Spawning / waiting on the `git` process failed. #[error("git process error: {0}")] Io(String), } impl GitError { /// Stable `op-i18n` key for this error variant. `op-git` stays /// locale-free (no `op-i18n` dependency); the desktop host /// translates this key when surfacing the error in a dialog. /// The `Display` impl remains the English fallback. pub fn i18n_key(&self) -> &'static str { match self { GitError::GitNotFound => "git.gitError.notFound", GitError::NotARepo(_) => "git.gitError.notARepo", GitError::Command { .. } => "git.gitError.commandFailed", GitError::MergeInProgress => "git.gitError.mergeInProgress", GitError::WorkingTreeDirty => "git.gitError.workingTreeDirty", GitError::Io(_) => "git.gitError.io", } } /// The variant's technical detail — the repository path, the /// failing command's stderr, or the IO error text. Empty for /// variants that carry no payload. The host substitutes this /// into the `{{detail}}` slot of the translated message so the /// localized dialog still shows the actionable git output. pub fn i18n_detail(&self) -> String { match self { GitError::NotARepo(path) => path.display().to_string(), // Keep the failing subcommand — `commit` / `push` / … — // alongside its stderr so the dialog stays actionable. GitError::Command { operation, stderr } => { format!("git {operation}: {stderr}") } GitError::Io(text) => text.clone(), GitError::GitNotFound | GitError::MergeInProgress | GitError::WorkingTreeDirty => { String::new() } } } } /// A handle to a git repository — its working-tree root directory. #[derive(Debug, Clone)] pub struct GitRepo { workdir: PathBuf, /// Extra environment applied to every `git` invocation — set by /// [`GitRepo::with_auth_env`] so a network op (pull / push / /// fetch) authenticates with a stored credential or SSH key. /// Empty for an un-authenticated handle (git then uses its /// ambient credential helpers / ssh-agent). auth_env: Vec<(String, String)>, } /// The committer identity git would stamp on a new commit, read /// from git config (repo, then global). Either field is `None` when /// git config does not set it. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Author { /// `user.name`. pub name: Option, /// `user.email`. pub email: Option, } /// Run `git ` in `dir` with extra environment, mapping a /// missing executable to [`GitError::GitNotFound`]. pub(crate) fn git_output_env( dir: &Path, args: &[&str], env: &[(String, String)], ) -> Result { let mut command = Command::new("git"); command.current_dir(dir).args(args); for (key, value) in env { command.env(key, value); } command.output().map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { GitError::GitNotFound } else { GitError::Io(e.to_string()) } }) } /// Run `git ` in `dir`, mapping a missing executable to /// [`GitError::GitNotFound`]. pub(crate) fn git_output(dir: &Path, args: &[&str]) -> Result { Command::new("git") .current_dir(dir) .args(args) .output() .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { GitError::GitNotFound } else { GitError::Io(e.to_string()) } }) } /// Trimmed stderr text of a failed invocation. pub(crate) fn stderr_of(output: &Output) -> String { String::from_utf8_lossy(&output.stderr).trim().to_string() } /// Write `bytes` to `path` for a file that holds secret material /// (a credential store, a private key). /// /// On Unix the file is created with owner-only (`0600`) permissions /// *before* any content is written — so the secret is never, even /// momentarily, world-readable. A naive `fs::write` followed by a /// `chmod` leaves exactly that window open. A stale file at `path` /// is removed first so the fresh file is created with the strict /// mode rather than inheriting a looser one. pub(crate) fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), GitError> { use std::io::Write; match std::fs::remove_file(path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(GitError::Io(e.to_string())), } let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } let mut file = options .open(path) .map_err(|e| GitError::Io(e.to_string()))?; file.write_all(bytes) .map_err(|e| GitError::Io(e.to_string()))?; Ok(()) } impl GitRepo { /// Discover the git repository that contains `path` (a document /// file or a directory). Returns `Ok(None)` when `path` is not /// inside any repository — that is a normal state, not an error. pub fn discover(path: &Path) -> Result, GitError> { // A file's repo is found by probing its parent directory. let probe = if path.is_file() { path.parent().unwrap_or(path) } else { path }; if !probe.exists() { return Ok(None); } let output = git_output(probe, &["rev-parse", "--show-toplevel"])?; if !output.status.success() { // git exits non-zero outside a work tree — "no repo here". return Ok(None); } let top = String::from_utf8_lossy(&output.stdout).trim().to_string(); if top.is_empty() { return Ok(None); } Ok(Some(GitRepo { workdir: PathBuf::from(top), auth_env: Vec::new(), })) } /// `git init` a repository at `dir` (creating `dir` if needed) /// and return a handle to it. The initial branch is named `main`. pub fn init(dir: &Path) -> Result { std::fs::create_dir_all(dir).map_err(|e| GitError::Io(e.to_string()))?; let output = git_output(dir, &["init", "--initial-branch=main"])?; if !output.status.success() { return Err(GitError::Command { operation: "init".to_string(), stderr: stderr_of(&output), }); } GitRepo::discover(dir)?.ok_or_else(|| GitError::NotARepo(dir.to_path_buf())) } /// The repository's working-tree root. pub fn workdir(&self) -> &Path { &self.workdir } /// A handle to the same repository whose `git` invocations carry /// `env` — set by [`GitRepo::auth_env`] so a network op runs with /// a stored credential / SSH key. An empty `env` is a no-op. pub fn with_auth_env(&self, env: Vec<(String, String)>) -> GitRepo { GitRepo { workdir: self.workdir.clone(), auth_env: env, } } /// The committer identity git would use here — `user.name` / /// `user.email` resolved through the repo + global config. An /// unset field is `None`; this never errors (a missing `git` /// just yields an empty [`Author`]). pub fn author(&self) -> Author { Author { name: self.config_get("user.name"), email: self.config_get("user.email"), } } /// Read a single git config value, or `None` when it is unset. fn config_get(&self, key: &str) -> Option { let output = git_output(&self.workdir, &["config", "--get", key]).ok()?; if !output.status.success() { return None; } let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); (!value.is_empty()).then_some(value) } /// Run `git ` in this repo, returning trimmed stdout on a /// zero exit. Shared by every operation in the sibling modules. pub(crate) fn run(&self, args: &[&str]) -> Result { let output = git_output_env(&self.workdir, args, &self.auth_env)?; if !output.status.success() { return Err(GitError::Command { operation: args.first().copied().unwrap_or("git").to_string(), stderr: stderr_of(&output), }); } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } } // Integration tests — `tests` is the spine (shared fixtures); the // per-topic test files are flat sibling modules, each under the // 800-line file cap. #[cfg(test)] mod tests; #[cfg(test)] mod tests_auth; #[cfg(test)] mod tests_merge; #[cfg(test)] mod tests_repo;