openpencil/crates/op-git/src/lib.rs

312 lines
11 KiB
Rust

//! 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<String>,
/// `user.email`.
pub email: Option<String>,
}
/// Run `git <args>` 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<Output, GitError> {
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 <args>` in `dir`, mapping a missing executable to
/// [`GitError::GitNotFound`].
pub(crate) fn git_output(dir: &Path, args: &[&str]) -> Result<Output, GitError> {
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<Option<GitRepo>, 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<GitRepo, GitError> {
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<String> {
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 <args>` 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<String, GitError> {
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;