312 lines
11 KiB
Rust
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;
|