openpencil/crates/op-git/src/lib.rs
Kayshen-X 82d481547c feat(panels): git-panel commit-detail diff, overflow menu, signature form
Bring the Rust git panel to TS parity across four surfaces:

- Commit rows expand inline into a semantic diff card (ported
  diffDocuments / indexNodesById / engineDiff over serde_json::Value)
  instead of navigating to a separate diff page; the card shows the
  "~modify N nodes" summary plus a per-node patch list. blob_at_commit
  reads the {rev} and {rev}^ blobs to compute base-vs-next.
- Overflow menu (...) with the 5 TS actions — switch tracked file, clear
  commit author, remote settings, SSH keys, close repository — backed by
  GitOverflowView subviews (tracked-file picker + SSH-keys list) and
  remote-settings rows (origin URL + ahead/behind + fetch; no HTTPS token
  field, matching TS).
- Commit signature form: empty commits are refused, and committing with no
  configured author opens an inline name/email form (Enter saves, Escape
  cancels) that writes the repo-local identity before retrying the commit.
- Polish: taller commit-detail card, removed panel shadow, lengthened the
  ready panel, and unified the commit-textarea caret blink to the app's
  500 ms cadence.

New overflow/SSH/author/remote labels resolve via Document::t with an
English fallback for keys not yet in the locale tables.
2026-06-02 09:42:03 +08:00

327 lines
13 KiB
Rust

//! 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/`). EVERY operation runs through
//! in-process **libgit2** (`git2`, vendored), so the shipped binary
//! carries its own git engine — there is no `std::process::Command`
//! and no dependency on a system `git` executable at runtime. (The
//! former subprocess backend failed under macOS TCC when the child
//! process touched a sandboxed directory, and broke entirely on
//! machines without git installed.)
//!
//! ## Scope
//!
//! Repo lifecycle (discover / init / clone), working-tree status,
//! staging, commit, restore, branches, commit history / diff, remotes
//! and network ops (fetch / pull / push with credential callbacks), and
//! merge orchestration incl. worktree-isolated merges. Credential and
//! SSH-key storage live in `auth` / `ssh` (plain file I/O).
//!
//! Every operation returns a [`GitError`] on failure — a non-repo
//! path, or a libgit2 error mapped onto [`GitError::Command`] — and
//! never panics.
use std::path::{Path, PathBuf};
mod auth;
mod branch;
mod candidates;
mod history;
mod merge;
mod remote;
mod ssh;
mod status;
mod worktree;
pub use auth::{AuthStore, Credential};
pub use branch::Branch;
pub use candidates::CandidateOpFile;
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 libgit2 error maps onto the existing [`GitError::Command`] variant
/// (a non-zero op with its message) so the public error surface — the
/// host's `i18n_key` matches + dialogs — stays unchanged across the
/// subprocess → libgit2 migration.
impl From<git2::Error> for GitError {
fn from(e: git2::Error) -> Self {
GitError::Command {
operation: "libgit2".to_string(),
stderr: e.message().to_string(),
}
}
}
/// 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>,
}
/// 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);
}
match git2::Repository::discover(probe) {
Ok(repo) => Ok(Some(GitRepo {
// The work-tree root. A bare repo has none — fall back to
// the `.git` path so the handle is still well-formed.
workdir: repo
.workdir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| repo.path().to_path_buf()),
auth_env: Vec::new(),
})),
// Not inside any repository is a normal state, not an error.
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Initialize 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 mut opts = git2::RepositoryInitOptions::new();
opts.initial_head("main");
git2::Repository::init_opts(dir, &opts)?;
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
}
/// Open the in-process libgit2 handle for this repository. Opening
/// is cheap (it just reads `.git`), so every operation opens fresh —
/// keeping [`GitRepo`] itself a plain `Clone + Send` `{workdir,
/// auth_env}` that the background pull / push / clone jobs can move
/// across threads (a `git2::Repository` is neither `Clone` nor `Send`).
pub(crate) fn open(&self) -> Result<git2::Repository, GitError> {
git2::Repository::open(&self.workdir).map_err(Into::into)
}
/// 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"),
}
}
/// Whether a committer identity is resolvable — both `user.name` and
/// `user.email` are set (repo → global → system). A commit refuses
/// without one, so the panel prompts for a signature when this is false.
pub fn has_committer_identity(&self) -> bool {
let a = self.author();
a.name.is_some() && a.email.is_some()
}
/// Write `user.name` / `user.email` into the repo-LOCAL config (the
/// signature for commits in this repo). The inverse of
/// [`Self::unset_local_author`]; used by the commit-signature form.
pub fn set_local_author(&self, name: &str, email: &str) -> Result<(), GitError> {
let repo = self.open()?;
let mut local = repo
.config()?
.open_level(git2::ConfigLevel::Local)
.or_else(|_| repo.config())?;
local.set_str("user.name", name)?;
local.set_str("user.email", email)?;
Ok(())
}
/// Read a single git config value (repo config, falling back to the
/// global/system config the way `git config --get` does), or `None`
/// when it is unset.
fn config_get(&self, key: &str) -> Option<String> {
let repo = self.open().ok()?;
let cfg = repo.config().ok()?;
// `Config::get_string` already walks repo → global → system.
cfg.get_string(key).ok().filter(|v| !v.is_empty())
}
/// Test-only porcelain escape hatch: run `git <args>` in this repo
/// and return trimmed stdout. Used ONLY by the integration-test
/// fixtures to *set up* repositories (seed commits, branches,
/// remotes) the quick way; the shipped library is pure libgit2 with
/// no subprocess, so this is gated out of every non-test build.
#[cfg(test)]
pub(crate) fn run(&self, args: &[&str]) -> Result<String, GitError> {
let output = std::process::Command::new("git")
.current_dir(&self.workdir)
.args(args)
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
GitError::GitNotFound
} else {
GitError::Io(e.to_string())
}
})?;
if !output.status.success() {
return Err(GitError::Command {
operation: args.first().copied().unwrap_or("git").to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
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;